commit 74983ce5ec96b787d153cade8d9cc6f430c8aa79 Author: 小鱼开发 Date: Mon Apr 20 16:39:57 2026 +0800 feat: init meijiaka-zj project from ai-meijiaka template diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..6207170 Binary files /dev/null and b/.DS_Store differ diff --git a/.cursor/rules/config-architecture.mdc b/.cursor/rules/config-architecture.mdc new file mode 100644 index 0000000..669f8dc --- /dev/null +++ b/.cursor/rules/config-architecture.mdc @@ -0,0 +1,43 @@ +# 配置架构规则 + +description: 配置管理架构规范 + +## 规则 + +### 配置读取 +- 所有配置必须通过 `from app.config import get_settings` 读取 +- 禁止直接使用 `os.getenv()` 或 `os.environ.get()` +- 禁止在服务层、API 层直接使用环境变量 + +### 添加新配置 +1. 在 `app/config.py` 的 `Settings` 类中定义字段 +2. 使用 `Field(default=..., description="...")` 提供默认值和说明 +3. 敏感信息使用 `str | None = None` 类型 +4. 更新 `.env.example` 文档 + +### 在服务中使用配置 +```python +from app.config import get_settings + +def some_function(): + settings = get_settings() + api_key = settings.SOME_API_KEY +``` + +### 禁止的写法 +```python +import os + +# ❌ 禁止 +api_key = os.getenv("SOME_API_KEY") +api_key = os.environ.get("SOME_API_KEY") +``` + +### 推荐的写法 +```python +from app.config import get_settings + +# ✅ 正确 +settings = get_settings() +api_key = settings.SOME_API_KEY +``` diff --git a/.playwright-mcp/console-2026-04-15T09-07-14-136Z.log b/.playwright-mcp/console-2026-04-15T09-07-14-136Z.log new file mode 100644 index 0000000..d3d34e8 --- /dev/null +++ b/.playwright-mcp/console-2026-04-15T09-07-14-136Z.log @@ -0,0 +1,9 @@ +[ 117ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:1420/node_modules/.vite/deps/react-dom_client.js?v=a15e99c2:20102 +[ 148ms] [LOG] [ScriptCreation] segments changed: 0 @ http://localhost:1420/src/pages/VideoCreation/ScriptCreation.tsx:52 +[ 148ms] [LOG] [ScriptCreation] segments changed: 0 @ http://localhost:1420/src/pages/VideoCreation/ScriptCreation.tsx:52 +[ 152ms] [ERROR] [authStore] 加载认证状态失败: TypeError: Cannot read properties of undefined (reading 'invoke') + at invoke (http://localhost:1420/node_modules/.vite/deps/chunk-G7S6KQDI.js?v=a15e99c2:109:37) + at loadFromStorage (http://localhost:1420/src/store/authStore.ts:72:28) @ http://localhost:1420/src/store/authStore.ts:83 +[ 152ms] [ERROR] [authStore] 加载认证状态失败: TypeError: Cannot read properties of undefined (reading 'invoke') + at invoke (http://localhost:1420/node_modules/.vite/deps/chunk-G7S6KQDI.js?v=a15e99c2:109:37) + at loadFromStorage (http://localhost:1420/src/store/authStore.ts:72:28) @ http://localhost:1420/src/store/authStore.ts:83 diff --git a/.playwright-mcp/page-2026-04-15T09-07-14-314Z.yml b/.playwright-mcp/page-2026-04-15T09-07-14-314Z.yml new file mode 100644 index 0000000..dc59250 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T09-07-14-314Z.yml @@ -0,0 +1,31 @@ +- generic [ref=e4]: + - generic [ref=e5]: + - generic [ref=e6]: + - img "美家卡 智影" [ref=e7] + - generic [ref=e8]: 美家卡 智影 + - paragraph [ref=e9]: AI 驱动的智能视频创作平台 + - generic [ref=e10]: + - heading "欢迎登录" [level=2] [ref=e11] + - paragraph [ref=e12]: 使用手机号验证码快速登录 + - generic [ref=e13]: + - generic [ref=e14]: + - generic [ref=e15]: 手机号 + - generic [ref=e16]: + - generic [ref=e17]: "+86" + - textbox "请输入手机号" [active] [ref=e18] + - generic [ref=e19]: + - generic [ref=e20]: 验证码 + - generic [ref=e21]: + - textbox "请输入验证码" [ref=e22] + - button "获取验证码" [disabled] [ref=e23] + - button "登录" [disabled] [ref=e24] + - generic [ref=e25]: + - checkbox "我已阅读并同意《用户服务协议》和《隐私政策》" [ref=e26] [cursor=pointer] + - generic [ref=e27] [cursor=pointer]: + - text: 我已阅读并同意 + - link "《用户服务协议》" [ref=e28]: + - /url: "#" + - text: 和 + - link "《隐私政策》" [ref=e29]: + - /url: "#" + - generic [ref=e30]: meijiaka.cn \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb5e9e0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,784 @@ + +# 美家卡智影 (Meijiaka AI Video) - AI 视频创作平台 + +## 项目概述 + +美家卡智影是一个 AI 驱动的视频创作桌面应用,采用 **Tauri + React + FastAPI** 混合架构。用户可以通过 AI 生成脚本、创建数字人视频,最终合成完整的营销视频。 + +### 核心功能 + +- **AI 脚本生成**: 基于 LLM 自动生成视频脚本和分镜 +- **数字人视频**: 基于 KlingAI 创建数字人视频片段 +- **字幕生成**: 基于火山引擎豆包语音自动生成字幕并压制到视频 +- **封面制作**: 提取视频首帧并叠加字幕样式生成封面 +- **视频合成**: 本地 FFmpeg 处理视频拼接、音频混流、导出成品 +- **形象克隆**: 基于 KlingAI 的自定义数字人形象管理 +- **项目管理**: 项目数据本地 JSON 文件存储,认证状态云端同步 + +## 项目结构 + +``` +ai-meijiaka/ +├── python-api/ # FastAPI 后端服务(AI 代理 + 认证 + 任务调度) +│ ├── app/ +│ │ ├── api/v1/ # API 路由 (REST): auth, script, ai_models, klingai, +│ │ │ # qiniu, video, avatar, system, +│ │ │ # caption, tasks +│ │ ├── ai/ # AI 模型路由、Provider、提示词模板 +│ │ ├── core/ # 安全、配置加载、Token管理器、Redis客户端、异常处理 +│ │ ├── crud/ # 数据访问层(users, model_usage, avatar) +│ │ ├── db/ # 数据库配置(PostgreSQL + asyncpg + SQLAlchemy 2.0) +│ │ ├── models/ # SQLAlchemy 模型(users, model_usage_logs, avatars) +│ │ ├── schemas/ # Pydantic 校验模型 +│ │ ├── services/ # AI 服务代理、DTO标准化、七牛/字幕/视频服务 +│ │ ├── scheduler/ # Async Engine 异步任务调度(video, image, script, +│ │ │ # subtitle, copy, avatar_clone) +│ │ ├── config.py # Pydantic Settings 配置管理 +│ │ └── main.py # FastAPI 入口(含生命周期管理) +│ ├── config/ # AI 模型配置文件(ai_models.yaml),支持热重载 +│ ├── alembic/ # 数据库迁移 +│ ├── scripts/ # 初始化/测试脚本 +│ ├── pyproject.toml # Python 依赖和工具配置 +│ ├── requirements.lock # uv 锁定依赖版本 +│ ├── Makefile # 常用命令封装 +│ ├── docker-compose.yml +│ └── Dockerfile +│ +├── tauri-app/ # Tauri 桌面应用(业务数据本地存储) +│ ├── src/ # React 前端源码 +│ │ ├── api/ +│ │ │ ├── adapters/ # 数据转换层(前后端字段映射) +│ │ │ ├── generated/ # OpenAPI 自动生成类型(只读) +│ │ │ ├── modules/ # API 模块封装(HTTP + IPC) +│ │ │ ├── client.ts # HTTP 客户端(自动 camelCase↔snake_case) +│ │ │ ├── types.ts # 手写核心类型 +│ │ │ └── ipc.ts # Tauri IPC 调用封装 +│ │ ├── components/ # 可复用组件 +│ │ ├── pages/ # 页面组件 +│ │ ├── store/ # Zustand 状态管理(+ Immer + persist) +│ │ ├── hooks/ # 自定义 React Hooks +│ │ ├── styles/ # 全局 CSS 变量、主题 +│ │ └── utils/ # 工具函数 +│ ├── src-tauri/ # Rust 后端源码 +│ │ ├── src/ +│ │ │ ├── lib.rs # Tauri 应用入口,命令注册 +│ │ │ ├── ffmpeg_cmd.rs # FFmpeg 命令封装 +│ │ │ ├── video_processing.rs # 视频合成业务逻辑 +│ │ │ ├── storage/ # 本地存储引擎(原子写入、文件锁、路径净化) +│ │ │ ├── commands/ # IPC 命令按领域拆分(project/asset/auth/avatar) +│ │ │ ├── api_proxy.rs # Python API 代理转发 +│ │ │ ├── auth.rs # 认证命令(已迁移至 commands/auth_state.rs) +│ │ │ ├── avatar_cache.rs # 头像缓存管理 +│ │ │ └── utils.rs # 通用工具函数 +│ │ ├── Cargo.toml +│ │ ├── tauri.conf.json +│ │ └── binaries/ # 嵌入式 FFmpeg +│ ├── package.json +│ ├── vite.config.ts +│ ├── tsconfig.json +│ └── eslint.config.js +│ +└── docs/ # 项目文档 + ├── anytocopy-api.md + ├── anytocopy-integration.md + ├── app-update-system.md + ├── database-design.md + ├── kling-api-dev.md + ├── migrate-avatars-to-local.md + ├── qiniu-kodo-python-sdk-guide.md + ├── video-generation-flow.md + └── volcengine-video-caption-api.md +``` + +## 技术栈 + +### 后端 (python-api) + +**⚠️ Python 版本要求: 3.13+** (项目使用 `|` 类型注解语法) + +| 组件 | 技术 | 版本 | 用途 | +|------|------|------|------| +| Python | - | 3.13+ | 运行环境 | +| Web 框架 | FastAPI | 0.116+ | REST API | +| 数据库 | PostgreSQL | 15+ | 用户认证 + 成本统计 + 形象管理 | +| ORM | SQLAlchemy | 2.0 (异步) | 数据模型 | +| 缓存/调度 | Redis + Async Engine | 5.2+ / 自定义 | 异步任务槽位调度 | +| AI SDK | OpenAI / volcengine | 1.58+ / 5.0+ | LLM 调用 | +| 认证 | python-jose + passlib | 3.4+ / 1.7+ | JWT 认证 | +| 对象存储 | qiniu | 7.13+ | 七牛云存储 | +| HTTP 客户端 | httpx + aiohttp | 0.28+ / 3.13+ | 异步 HTTP | +| 包管理/构建 | uv | - | 虚拟环境、依赖锁定、Docker 构建 | + +**后端架构说明**: +- 后端为"轻量云账号 + 全本地业务数据"模式 +- 云端仅存储:用户账户、形象元数据、成本统计 +- 业务数据(项目/脚本/媒体)全部本地存储 +- 任务调度使用**自定义 Async Engine**(基于 Redis 的槽位管理),**非 Celery** + +### 前端 (tauri-app) + +| 组件 | 技术 | 版本 | 用途 | +|------|------|------|------| +| 桌面框架 | Tauri | 2.x | 桌面应用壳 | +| UI 框架 | React | 19.1+ | 用户界面 | +| 路由 | React Router DOM | 7.x | 页面路由(主壳使用 NavigationContext) | +| 状态管理 | Zustand | 5.x | 全局状态 + Immer 中间件 | +| 数据获取 | SWR | 2.x | 请求缓存 | +| 虚拟列表 | @tanstack/react-virtual | 3.x | 大数据列表渲染 | +| 构建工具 | Vite | 7.x | 构建、开发服务器 | +| 测试 | Vitest + @testing-library | 4.x | 单元测试 | +| 类型生成 | openapi-typescript | 7.x | 从 OpenAPI 生成 TS 类型 | + +### Rust 后端 (src-tauri/src) + +| 模块 | 用途 | +|------|------| +| lib.rs | Tauri 应用入口,命令注册 | +| ffmpeg_cmd.rs | FFmpeg 命令封装(首帧提取、字幕压制、封面合成) | +| video_processing.rs | 视频合成业务逻辑 | +| storage/engine.rs | 本地存储引擎(原子写入、文件锁、路径净化) | +| storage/paths.rs | 集中化路径计算 | +| commands/project.rs | 项目本地存储 IPC 命令 | +| commands/asset.rs | 资源文件保存 IPC 命令 | +| commands/auth_state.rs | 认证状态文件持久化 | +| api_proxy.rs | Python API 代理转发 | +| avatar_cache.rs | 头像视频缓存管理 | + +## 开发环境搭建 + +### 1. 启动 Python 后端 + +```bash +cd python-api + +# 方式一:Docker Compose(推荐) +cp .env.example .env +docker-compose up -d + +# 方式二:本地开发(若 Docker 不可用) +# 启动 PostgreSQL 和 Redis +docker-compose up -d db redis + +# 安装依赖(使用 uv) +uv pip install -e ".[dev]" + +# 启动开发服务器(注意:Docker API 会占用 8080 端口) +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 另开终端启动 Async Engine Scheduler(必须同时启动,否则任务不会执行) +python -m app.scheduler.main +``` + +后端服务地址: +- API: http://localhost:8080/api/v1 +- 文档: http://localhost:8080/docs +- 健康检查: http://localhost:8080/health + +**Docker Compose 服务组成**(4 个服务): +- `db`: PostgreSQL 15 +- `redis`: Redis 7 +- `api`: FastAPI 开发服务器(端口 8080→8000) +- `scheduler`: Async Engine 统一调度器,处理所有第三方异步任务 + +### 2. 启动 Tauri 前端 + +```bash +cd tauri-app + +# 安装依赖 +npm install + +# 开发模式(自动启动 Vite + Tauri) +npm run tauri dev +``` + +前端窗口: +- Vite 开发服务器: http://localhost:1420 +- 应用窗口: 1440×960(最小 960×640,可调整大小) + +## 构建命令 + +### Python 后端 + +```bash +cd python-api + +# 使用 Makefile(推荐) +make dev # 安装开发依赖 + pre-commit 钩子 +make lint # ruff + mypy +make format # black + ruff --fix +make test # pytest +make test-cov # 覆盖率报告 +make security # bandit + pip-audit +make lint-semantic # 语义层禁词检查 +make ci # 运行所有 CI 检查(format-check + lint + lint-semantic + test + security) +make docker-run # Docker Compose 启动全部服务 +make scheduler # 启动 Async Engine Scheduler + +# 手动命令 +black app/ +ruff check app/ +mypy app/ +bandit -c pyproject.toml -r app/ +pip-audit +pytest +pytest --cov=app + +# 导出 OpenAPI 文档到前端 +python3 -c " +import logging +logging.disable(logging.WARNING) +from app.main import app +import json +print(json.dumps(app.openapi(), indent=2, ensure_ascii=False)) +" > ../tauri-app/src/api/generated/openapi.json + +# Docker 构建 +docker build -t meijiaka-api . +``` + +### Tauri 前端 + +```bash +cd tauri-app + +# 开发 +npm run dev # 纯 Vite 开发(不启动 Tauri) +npm run tauri dev # 完整 Tauri 开发模式 + +# 构建 +npm run build # 前端生产构建 +npm run tauri build # 打包桌面应用 + +# 测试 +npm run test # 运行 Vitest +npm run test:ui # UI 模式 +npm run test:coverage # 覆盖率报告 + +# 代码质量 +npm run lint # ESLint 检查 +npm run lint:fix # ESLint 自动修复 +npm run format # Prettier 格式化 +npm run format:check # Prettier 格式检查 +npm run stylelint # CSS 检查 +npm run stylelint:fix # CSS 自动修复 + +# 类型生成 +npm run gen:api # 从 OpenAPI 生成 TypeScript 类型 +``` + +## 架构说明 + +### 混合路由架构 + +前端 API 调用采用 **智能路由** 策略: + +1. **HTTP 直连 Python**: 纯数据 API(脚本生成、模型管理、任务轮询等) +2. **Tauri IPC → Rust**: 需要本地能力的 API(FFmpeg、文件系统) + +路由决策在 `tauri-app/src/api/client.ts` 中实现。HTTP 客户端会自动处理 `camelCase` ↔ `snake_case` 字段名转换。需要走 Rust IPC 的 API 包括: +- `video_composite_synthesis` // FFmpeg 视频合成 +- `burn_subtitle` // 字幕压制 +- `extract_video_first_frame` // 首帧提取 +- `generate_cover_image` // 封面生成 +- `save_project_meta*` / `load_project_meta*` // 本地文件系统 +- `save_project_segments*` / `load_project_segments*` +- `save_project_asset` / `get_video_save_path` / `get_image_save_path` +- `save_final_product` +- 头像缓存相关 API + +**添加新 API 流程**: +1. Python 端实现端点 +2. 前端直接调用(默认 HTTP) +3. 仅当需要本地能力时,在 Rust 中添加命令并在 `lib.rs` 注册 + +### AI Provider 架构 + +后端 AI 模块采用 **多 Provider 路由** 设计: + +``` +app/ai/ +├── model_router.py # 模型路由器(自动降级) +├── providers/ +│ ├── base.py # Provider 抽象基类 +│ ├── generic_llm_provider.py # 通用 OpenAI 兼容 Provider +│ ├── volcengine_provider.py # 火山方舟官方 SDK +│ └── klingai_provider.py # KlingAI 数字人 +└── prompts/ # 提示词模板(禁止硬编码) +``` + +支持的 AI 平台: +- **火山方舟** (字节跳动) - 推荐,性价比高 +- **OpenAI** - GPT 系列 +- **文心一言** (百度) +- **通义千问** (阿里云) +- **可灵 AI** (快手) - 视频生成、数字人、形象克隆 + +AI 模型配置位于 `python-api/config/ai_models.yaml`,支持热重载,无需重启服务即可更新模型配置。 + +### Async Engine(异步任务调度) + +**⚠️ 重要:项目不使用 Celery,使用自定义 Async Engine** + +架构: +``` +API (POST /tasks/{type}) → Redis JobRegistry → AsyncEngine tick loop → Handlers +``` + +组件: +- **`AsyncEngine`** (`app/scheduler/engine.py`): 每 ~10s 执行 `tick()`,加载运行中任务,按类型分组,并行分发给 Handler,通过 Pipeline 应用 `StateChange`,清理已完成任务 +- **`JobRegistry`** (`app/scheduler/registry.py`): Redis-based 任务 CRUD,使用 `job:{id}` hash + `scheduler:running_tasks` SET +- **`SlotManager`** (`app/scheduler/slot_manager.py`): Redis Lua 原子脚本实现并发槽位抢占/释放 +- **`JobRecord`** / **`StateChange`** (`app/scheduler/models.py`): 调度器内部类型 + +已注册的 Handler(`app/scheduler/main.py`): + +| Handler | 槽位数 | Redis Key | 用途 | +|---------|--------|-----------|------| +| VideoHandler | 18 | `kling:video_slots` | Kling 视频生成(omni + image2video) | +| ImageHandler | 9 | `kling:image_slots` | Kling 图片生成 | +| ScriptHandler | 10 | `script:slots` | LLM 脚本生成(含 AnyToCopy 视频文案提取) | +| SubtitleHandler | 5 | `volc:subtitle_slots` | 火山引擎字幕/自动对齐 | +| CopyHandler | 5 | `anytocopy:slots` | AnyToCopy 视频文案提取 | +| AvatarHandler | 2 | `kling:avatar_slots` | Kling 形象克隆(状态机: pending→voice_processing→element_pending→element_processing→succeed) | + +### TokenManager(API 认证 Token 管理) + +`app/core/token_manager.py` 提供通用的 API 认证 Token 缓存与自动刷新: + +```python +from app.core.token_manager import JWTTokenStrategy, TokenManager + +class MyProvider: + def __init__(self, access_key: str, secret_key: str): + self._token_strategy = JWTTokenStrategy( + access_key=access_key, + secret_key=secret_key, + expires_in=1800, # 30分钟 + ) + + 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}"} +``` + +**特性**: +- Token 缓存(避免重复生成) +- 自动刷新(Token 即将过期时自动刷新) +- 并发安全(双重检查锁定,确保并发请求只生成一次 Token) +- 后台预热(提前 10 分钟刷新,避免请求时等待) +- 支持 JWT、OAuth2 等多种策略 + +### 本地存储引擎(Rust) + +Rust 层实现了 defense-in-depth 的本地存储系统:`src-tauri/src/storage/` + +- **`engine.rs`**: 核心原子操作 + - `sanitize_id()` — 白名单 `[a-zA-Z0-9_-]+`,防御路径遍历 + - `sanitize_filename()` — 提取纯文件名,拒绝目录组件 + - `atomic_write_json()` / `atomic_write_bytes()` — 先写 `.tmp` 再 `rename` 原子替换 + - `with_file_lock()` — 通过 `fs2` 实现独占文件锁 + - `read_json()` — 安全读取,文件不存在返回 `None` +- **`paths.rs`**: 集中路径计算 + - `~/Documents/Meijiaka/projects/{id}/` (meta.json, segments.json, assets/) + - `~/Documents/Meijiaka/products/` + - `{app_config_dir}/auth.json` + - `{app_data_dir}/avatars/` + +**所有本地 JSON 读写必须经过 StorageEngine,禁止在命令处理器中直接调用 `fs::write`**。 + +### 数据库模型 + +后端仅保留 **3 个表**: + +``` +users -- 用户账户信息(mobile, nickname, avatar_url) +model_usage_logs -- 大模型调用记录(token, 成本, 响应时间) +avatars -- 克隆形象元数据(云端备份,前端已迁移至本地 JSON) +``` + +**业务数据本地存储**: +- 项目/脚本/分镜 → 前端本地 JSON 文件(`~/Documents/Meijiaka/projects/`) +- 音频/视频/图片文件 → 本地磁盘 +- 用户配置 → localStorage(少量 UI 状态) + +### 数据流规范 + +``` +用户输入主题 ──→ 后端 AI 生成脚本 ──→ 后端返回分镜列表 ──→ 前端保存到本地 + │ │ + └────────────────── 后端不存储脚本数据 ────────────────────────┘ +``` + +### 本地存储结构 + +``` +~/Documents/Meijiaka/ # 用户文档目录 +├── config.json # 全局配置 +├── projects/ # 项目数据 +│ └── {project_id}/ +│ ├── meta.json # 项目元数据 +│ ├── segments.json # 分镜数据 +│ └── assets/ # 资源文件(封面、成品等) +├── products/ # 成品视频目录 +├── avatars.json # 形象列表(本地) +└── cache/ # 缓存目录 +``` + +项目元数据 `meta.json` 关键字段: +- `id`, `title`, `topic`, `status` (draft | published) +- `currentStep`: 1=脚本生成, 2=形象视频, 3=字幕压制, 4=封面制作, 5=视频合成 +- `createdAt`, `updatedAt`, `exportedAt` +- `coverPath`, `finalVideoPath` +- `selectedElementId`, `selectedHumanId` +- `coverConfig`, `scriptDuration`, `scriptType` + +分镜数据 `segments.json` 字段: +- `id`, `type` (segment | empty_shot), `scene`, `voiceover`, `duration` +- `videoPath`, `videoUrl`, `elementId`, `voiceId` +- `alignmentResult`, `burnedVideoPath`, `burnedAt` + +### 前端导航 + +主应用壳使用 **自定义 NavigationContext**(React Context)实现页面切换,映射 `Record`。`react-router-dom` 已安装但主要用于未来扩展或特定路由场景,当前主流程不使用 BrowserRouter 进行导航。 + +### 状态管理 + +六個專門的 Zustand store: + +| Store | 职责 | 持久化 | +|-------|------|--------| +| `authStore` | JWT、UserInfo、登录/登出 | Tauri `auth.json`(或 localStorage fallback) | +| `projectStore` | 分镜、currentStep、选题、封面配置 | **仅 UI 标志**通过 `persist`;业务数据显式写入本地 JSON | +| `taskStore` | 异步任务状态/进度/消息 | **无**(内存 only,真相源在后端 Redis) | +| `uiStore` | Toast 通知队列 | 无 | +| `progressStore` | 全局进度模态框 | 无 | +| `settingsStore` | 主题模式、用户偏好 | localStorage | + +`projectStore` **不自动保存**。数据在显式过渡点持久化到磁盘(如进入 step 2、调用 `setFinalVideoPath` 时触发 `saveMetaToLocalFile`)。`saveMetaToLocalFile()` 通过 Promise 链串行化写入,避免并发覆盖。 + +## 开发规范 + +### 核心原则 + +1. **后端环境优先使用 Docker Compose**: 开发时通过 `docker-compose up -d` 启动后端。前端默认连接 `http://127.0.0.1:8080/api/v1`。 +2. **接口契约优先**: 后端承诺无论使用什么 AI 模型,输出永远符合同一个 Schema +3. **类型单一来源**: 后端 Schema 是权威,前端通过 OpenAPI 生成类型 +4. **Adapter 层隔离**: 前后端字段差异只允许在 Adapter 层处理 +5. **数据库分层**: API → Service → CRUD → Model,禁止跨层调用 +6. **提示词文件化**: 除前端输入外,后端不允许硬编码任何 Prompt +7. **配置统一管理**: 所有配置通过 `get_settings()` 读取,禁止直接使用 `os.getenv()` +8. **本地存储必须经过 StorageEngine**: Rust 层所有文件操作使用 `atomic_write_json` + `with_file_lock` + +### 配置管理规范 + +**架构层级:** +``` +.env (Layer 1) ──→ Settings (Layer 2) ──→ 服务层 (Layer 3) + ↑ + 唯一配置出口 +``` + +**强制规范:** +- **所有服务**必须使用 `from app.config import get_settings` 读取配置 +- **禁止**在服务层使用 `os.getenv()` 或 `os.environ.get()` +- **所有配置项**必须在 `app/config.py` 的 `Settings` 类中定义 +- **敏感信息**(API Keys、Secrets)必须通过环境变量注入 +- **业务默认值**可以硬编码在 `Settings` 中 + +**添加新配置流程:** +1. 在 `app/config.py` 的 `Settings` 类中添加字段定义 +2. 在 `.env` 中添加实际值(敏感信息)或使用默认值 +3. 在服务层通过 `get_settings()` 读取 +4. 更新 `.env.example` 文档 + +### 语义层防护网 + +项目强制执行语义分层,禁止供应商术语泄漏到业务层: + +| 层级 | 职责 | 禁词示例 | +|------|------|----------| +| Layer 6 (Presentation) | API Schema | `element_id`, `kling_task_id` | +| Layer 4 (Orchestration) | Scheduler | `task_id`(应使用 `job_id`) | +| Layer 3 (Domain) | Service | 供应商特定术语 | +| Layer 2 (Adapter) | Provider | 允许使用供应商原生术语 | + +Makefile 提供 `make lint-semantic` 进行自动化检查: +- API 层(除 `klingai.py`)禁止使用 `element_id`(应使用 `provider_element_id` 或 `human_id`) +- Scheduler 层禁止使用 `task_id`(应使用 `job_id`) +- 全局禁止 `kling_task_id`(应使用 `provider_task_id`) +- Scheduler Redis key 必须使用 `job:` 而非 `task:` + +### 快速参考 + +| 场景 | 正确做法 | +|------|---------| +| 后端换 AI 模型 | 修改 `services/ai_response_utils.py` 标准化层,不修改 Schema | +| 后端新增字段 | `Optional[T] = Field(None)`,向后兼容 | +| 后端修改字段 | 保留旧字段,标记 deprecated,逐步迁移 | +| 前端需要新字段 | Store 中 `extends` 基础类型 | +| 数据清洗 | **只在** Adapter 层,禁止在组件层 | +| 新增数据库实体 | 创建 Model → CRUD → API(分层开发)| +| 数据库查询 | 在 CRUD 层封装,API 层调用 | +| 事务管理 | API 层控制,通过 `get_db` 依赖注入 | +| 新增提示词 | 创建 `.txt` 文件,使用 `_load_prompt()` 加载 | +| 新增本地文件操作 | 使用 `storage::engine` 原子写入 + 文件锁 | + +### 后端分层架构 + +``` +API Layer (api/v1/*.py) + ↓ 调用 +Service Layer (services/*.py) - 可选,复杂业务 + ↓ 调用 +CRUD Layer (crud/*.py) + ↓ 调用 +Model Layer (models/*.py) + ↓ 调用 +Database Layer (db/*.py) +``` + +**禁止**: +- API 层直接操作 Model +- CRUD 层返回 Schema(应返回 Model) +- Service 层直接操作数据库(应通过 CRUD) +- 在业务代码中写 SQL + +### 前端类型规范 + +| 层级 | 类型来源 | 说明 | +|------|----------|------| +| 后端 Schema | `python-api/app/schemas/*.py` | Pydantic 模型,OpenAPI 生成源 | +| 前端基础类型 | `tauri-app/src/api/types.ts` | 手写的核心类型,与后端对齐 | +| 前端完整类型 | `tauri-app/src/api/generated/schema.ts` | OpenAPI 自动生成,只读 | +| Store 扩展 | `tauri-app/src/store/*.ts` | `extends` 基础类型添加前端字段 | + +### 间距规范 + +前端使用基于 4px 的网格系统,定义在 `tauri-app/src/styles/variables.css`: + +| 变量 | 值 | 使用场景 | +|------|-----|----------| +| `--spacing-2xs` | 2px | 微调控件、边框线 | +| `--spacing-xs` | 4px | 紧凑间隙、图标边距 | +| `--spacing-sm` | 8px | 小间隙、按钮内边距-y | +| `--spacing-md` | 12px | 标准间隙、卡片内边距 | +| `--spacing-lg` | 16px | 大间隙、区块间距 | +| `--spacing-xl` | 24px | 页面区块、内容分隔 | +| `--spacing-2xl` | 32px | 大区块间距、页面边距 | +| `--spacing-3xl` | 48px | 页面级间距、Hero 区域 | + +## 代码风格 + +### Python + +- **格式化**: Black (line-length: 100, target-version: py313) +- **检查**: Ruff (E, F, I, N, W, UP, B, C4, SIM) +- **类型**: MyPy(非严格模式全局,但 `app.schemas.*`、`app.crud.*`、`app.scheduler.handlers.*` 强制严格模式) +- **文档**: 中文注释,Google Style Docstrings +- **安全**: Bandit + pip-audit +- **Git Hooks**: pre-commit(Black、Ruff、uv lock 同步检查) + +### TypeScript/React + +- **类型**: 严格 TypeScript 模式(`strict: true`, `noUnusedLocals: true`, `noUnusedParameters: true`) +- **组件**: 函数组件 + Hooks +- **状态**: Zustand 管理全局状态(配合 Immer 处理不可变更新) +- **样式**: 普通 CSS + CSS 变量(`tauri-app/src/styles/variables.css`) +- **ESLint**: 使用 `eslint.config.js`(Flat Config),含 React Hooks 和 React Refresh 规则 +- **Prettier**: semi=true, singleQuote=true, tabWidth=2, printWidth=100 +- **Stylelint**: `stylelint-config-standard`,禁止 magic px 用于 `border-radius` 和 `font-size` + +### Rust + +- **格式化**: rustfmt +- **检查**: cargo clippy +- **注释**: 中文文档注释 + +### 提交规范 + +``` +feat: 新功能 +fix: 修复 +docs: 文档 +refactor: 重构 +test: 测试 +chore: 构建/工具 +``` + +## 测试策略 + +### 后端测试 + +```bash +cd python-api + +# 运行所有测试 +pytest -v + +# 覆盖率报告 +pytest --cov=app --cov-report=html --cov-report=term +``` + +**测试配置** (`pyproject.toml`): +- asyncio_mode = "auto" +- 测试文件命名: `test_*.py` + +> **注**:当前项目中 `python-api/tests/` 目录尚未创建,后端测试待补充。 + +### 前端测试 + +```bash +cd tauri-app + +# 运行 Vitest +npm run test + +# UI 模式 +npm run test:ui + +# 覆盖率报告 +npm run test:coverage +``` + +**测试配置**: +- 测试框架: Vitest 4.x + @testing-library/react + jsdom +- 测试文件: `src/**/*.test.ts(x)` +- Mock 配置: `src/__tests__/setup.ts` +- 自动 Mock: localStorage, Tauri API (`@tauri-apps/api/core`) +- 示例测试: `src/store/__tests__/authStore.test.tsx` + +## 安全注意事项 + +1. **SECRET_KEY**: 生产环境必须修改为强随机密钥(`get_settings()` 会在生产环境校验) +2. **CORS**: 生产环境限制为实际前端域名,开发环境 `DEBUG=true` 时允许所有来源 +3. **API Keys**: 不要提交到 Git,使用 `.env` 文件注入 +4. **FFmpeg**: 嵌入的二进制文件需验证来源 +5. **文件上传**: 限制文件类型和大小,防止攻击 +6. **路径遍历**: Rust StorageEngine 的 `sanitize_id()` 和 `sanitize_filename()` 防御路径遍历攻击 +7. **原子写入**: 所有本地 JSON 使用 `atomic_write_json`(先写 `.tmp` 再 `rename`) +8. **文件锁**: 并发 RMW 操作使用 `with_file_lock` 防止竞态 +9. **日志**: 后端日志写入 `~/Documents/Meijiaka/logs/api_YYYYMMDD.log` + +## 配置说明 + +### Python 后端 (.env) + +关键环境变量: + +```bash +# 数据库 (PostgreSQL) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 + +# JWT 密钥(生产环境必须修改) +SECRET_KEY=your-secret-key-here-change-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# AI API Keys +VOLCENGINE_API_KEY=your-volcengine-key +VOLCENGINE_CAPTION_APPID=your-caption-appid +VOLCENGINE_CAPTION_TOKEN=your-caption-token +KLINGAI_ACCESS_KEY=your-kling-access-key +KLINGAI_SECRET_KEY=your-kling-secret-key +OPENAI_API_KEY=sk-your-openai-key + +# 七牛云存储 +QINIU_ACCESS_KEY=your-qiniu-access-key +QINIU_SECRET_KEY=your-qiniu-secret-key +QINIU_VIDEO_BUCKET=media-liche +QINIU_IMAGE_BUCKET=img-liche + +# CORS 允许的前端地址 +CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 +``` + +### Tauri 配置 (tauri.conf.json) + +```json +{ + "productName": "美家卡智影", + "identifier": "cn.meijiaka.ai-video", + "build": { + "devUrl": "http://localhost:1420", + "frontendDist": "../dist" + }, + "bundle": { + "externalBin": ["binaries/ffmpeg"], + "resources": { + "fonts/*": "fonts/" + } + } +} +``` + +### AI 模型配置 (config/ai_models.yaml) + +模型配置文件支持热重载,无需重启服务即可更新模型配置。主要配置项: + +- **platforms**: AI 平台配置(mock, volcengine, klingai) +- **models**: 可用模型列表及其能力标签 [script, polish, chat, image, embedding, vision] +- **task_defaults**: 任务类型到模型的默认映射 + +## 视频创作流程 + +1. **脚本生成** (Step 1) - AI 生成视频脚本和分镜 +2. **形象视频** (Step 2) - 选择数字人形象,生成视频片段 +3. **字幕压制** (Step 3) - 生成字幕并压制到视频中 +4. **封面制作** (Step 4) - 生成视频封面 +5. **视频合成** (Step 5) - FFmpeg 拼接视频片段,导出最终视频 + +## 常见问题 + +### Q: 火山方舟如何配置? + +1. 注册火山引擎账号并实名认证 +2. 创建 API Key +3. 开通模型并创建推理接入点 +4. 在 `.env` 中设置 `VOLCENGINE_API_KEY` + +### Q: 可灵 AI 如何配置? + +1. 前往可灵 AI 开发者平台 https://klingai.com/document-api +2. 获取 Access Key 和 Secret Key +3. 在 `.env` 中设置 `KLINGAI_ACCESS_KEY` 和 `KLINGAI_SECRET_KEY` + +### Q: FFmpeg 在哪里? + +Tauri 应用已嵌入 FFmpeg 二进制文件: +- 位置: `tauri-app/src-tauri/binaries/ffmpeg-*` +- 使用: Rust 层通过 `ffmpeg_cmd` 模块调用 +- 打包时会作为 `externalBin` 资源嵌入 + +### Q: 后端换了 AI 模型,输出格式变了怎么办? + +修改 `services/ai_response_utils.py` 中的标准化函数,增加新的字段映射,**不要**修改 API Schema。 + +### Q: 如何新增/修改提示词? + +1. 创建文件: `app/ai/prompts/my_prompt.txt` +2. 加载使用: `prompt = self._load_prompt("my_prompt")` +3. **禁止**: 在 Python 代码中直接写 `"""你是一位..."""` + +### Q: 项目数据是如何持久化的? + +- 项目元数据(`meta.json`)和分镜数据(`segments.json`)保存在 `~/Documents/Meijiaka/projects/{project_id}/` +- 不通过 Zustand `persist` 保存项目数据,而是通过 `localProjectApi` 显式调用 Tauri IPC 写入文件 +- `projectStore` 的 `persist` 中间件仅保存少量 UI 状态 + +### Q: Async Engine 和 Celery 有什么区别? + +本项目使用**自定义 Async Engine** 替代 Celery: +- 基于 Redis 的槽位管理(SlotManager),限制各类型任务的并发数 +- 独立的 `scheduler` 进程(`python -m app.scheduler.main`) +- 每个 Handler 实现 `AsyncHandler` 接口,状态机驱动任务生命周期 +- 优势:更细粒度的并发控制、统一状态机、无 Celery 依赖 + +--- + +**最后更新**: 2026-04-17 +**架构模式**: 单机版(轻量云账号 + 全本地业务数据) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..673983d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,518 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +美家卡智影 (Meijiaka AI Video) - AI 视频创作平台。一个 AI 驱动的桌面应用,采用 **Tauri + React + FastAPI** 混合架构,用户可以通过 AI 生成脚本、创建数字人视频,自动生成字幕,最终本地合成完整的营销视频。 + +### 环境要求 + +| 组件 | 版本要求 | +|------|----------| +| Python | **3.13+** (代码使用 `|` 类型注解语法) | +| Node.js | 20+ | +| Rust | 1.70+ | +| Docker | 20+ (可选,用于数据库) | + +核心设计理念:**轻量云账号 + 全本地业务数据** - 云端只存储用户认证和使用日志,所有项目/脚本/媒体都存在用户本地。 + +## 架构 + +### 混合架构 + +- **FastAPI 后端**: 处理 AI 模型调用、用户认证、API 服务 +- **Tauri + React 前端**: 桌面 UI,React 负责渲染,Tauri 提供系统能力 +- **Rust 后端**: 通过 Tauri IPC 处理本地操作(FFmpeg 视频处理、文件系统访问) + +### 存储策略 + +核心设计理念:**轻量云账号 + 全本地业务数据** - 云端只存储用户认证和使用日志,所有项目/脚本/媒体都存在用户本地。 + +- **云端**: PostgreSQL 只存储 2 张表:`users` (用户账户)、`model_usage_logs` (用量统计) + - `avatars` 表已废弃:数字人名片元数据现在纯本地存储 `avatars.json` +- **本地**: JSON 文件存储项目/脚本/分镜数据、数字人元数据,用户磁盘存储媒体文件,FFmpeg 处理视频合成 +- **缓存/队列**: Redis + Async Engine Scheduler 处理异步任务 + +### 混合通信模式 + +| 通信模式 | 使用场景 | 前端调用方式 | +|---------|---------|------------| +| HTTP → FastAPI | AI 生成、认证、配置管理 | `client.get/post/put/delete()` | +| Tauri IPC → Rust | FFmpeg 视频处理、本地文件系统 | `ipc.request()` 或直接 `invoke()` | + +**通信模块**: +- `tauri-app/src/api/client.ts` - HTTP 客户端,自动处理 camelCase/snake_case 转换 +- `tauri-app/src/api/ipc.ts` - IPC 客户端 +- `tauri-app/src/api/modules/localStorage.ts` - 本地项目存储(走 IPC) +- `tauri-app/src/api/modules/videoComposite.ts` - 视频合成(走 IPC) + +### AI Provider 架构 + +后端 AI 模块采用多 Provider 设计: +- `app/ai/model_router.py` - 模型路由器,支持自动降级 +- `app/ai/providers/base.py` - 抽象基类 +- `app/ai/providers/*` - 具体实现(OpenAI、火山引擎、KlingAI 等) +- `app/ai/prompts/` - 提示词模板文件 + +支持的 AI 平台:火山方舟(推荐)、OpenAI、百度文心一言、阿里云通义千问、KlingAI(数字人视频生成)。 + +模型配置文件:`python-api/config/ai_models.yaml`(支持热重载) + +### Token 管理 + +外部 API 认证 Token 使用 `app/core/token_manager.py` 统一管理: +- Token 缓存(避免重复生成) +- 自动刷新(Token 即将过期时自动刷新) +- 并发安全(双重检查锁定) +- 支持 JWT、OAuth2 等多种策略 + +### 数据流 + +1. **脚本生成**: 用户输入 → FastAPI AI 代理 → 标准化输出 → 前端保存到本地 JSON +2. **数字人视频**: 后端调用 KlingAI API → 返回视频 URL → 前端下载并本地存储 +3. **视频合成**: 前端 → Tauri IPC → Rust 后端 → FFmpeg → 渲染最终视频文件 + +### 本地存储结构(用户机器) + +``` +~/Documents/Meijiaka/ +├── config.json # 全局应用配置 +├── projects/ +│ └── {project_id}/ +│ ├── meta.json # 项目元数据 +│ ├── segments.json # 脚本/分镜数据 +│ └── assets/ # 媒体文件 +├── avatars/ +│ └── {avatar_id}/ +│ ├── meta.json # 数字人名片配置 +│ └── source.mp4 # 源视频 +└── cache/ # 临时文件 +``` + +## 目录结构 + +``` +ai-meijiaka/ +├── python-api/ # FastAPI 后端服务 +│ ├── app/ +│ │ ├── api/v1/ # REST API 端点 +│ │ ├── ai/ # AI 模型路由和 Provider +│ │ ├── ai/prompts/ # 提示词模板文件 +│ │ ├── core/ # 安全、配置、异常处理 +│ │ ├── db/ # 数据库配置 +│ │ ├── models/ # SQLAlchemy 数据模型 +│ │ ├── schemas/ # Pydantic 验证模型 +│ │ ├── services/ # 业务逻辑和 AI 服务代理 +│ │ ├── scheduler/ # Async Engine 统一异步调度器 +│ │ ├── config.py # 配置管理 +│ │ └── main.py # 应用入口 +│ ├── config/ # AI 模型配置(YAML) +│ ├── tests/ # pytest 测试套件 +│ ├── scripts/ # 管理和测试脚本 +│ └── docker-compose.yml # Docker 服务编排 +│ +├── tauri-app/ # Tauri 桌面应用 +│ ├── src/ # React 前端源码 +│ │ ├── api/ # API 客户端和类型 +│ │ │ ├── adapters/ # 前后端字段差异适配 +│ │ │ ├── generated/ # OpenAPI 自动生成类型 +│ │ │ └── modules/ # API 模块封装 +│ │ ├── components/ # 可复用 React 组件 +│ │ ├── pages/ # 页面组件(路由) +│ │ ├── store/ # Zustand 全局状态管理 +│ │ ├── hooks/ # 自定义 React Hooks +│ │ └── utils/ # 前端工具函数 +│ ├── src-tauri/ # Rust 后端 +│ │ ├── src/ +│ │ │ ├── lib.rs # Tauri 应用入口,命令注册 +│ │ │ ├── commands/ # 按领域拆分的命令模块 +│ │ │ │ ├── asset.rs # 资源文件操作 +│ │ │ │ ├── auth_state.rs # 认证状态管理 +│ │ │ │ ├── avatar.rs # 数字人头像管理 +│ │ │ │ ├── product.rs # 产品相关 +│ │ │ │ └── project.rs # 项目存储操作 +│ │ │ ├── storage/ # 存储引擎分层 +│ │ │ │ ├── mod.rs # 模块导出 +│ │ │ │ ├── paths.rs # 路径计算 +│ │ │ │ ├── engine.rs # 核心存储引擎(原子写+文件锁) +│ │ │ │ ├── auth.rs # 认证存储 +│ │ │ │ ├── project.rs # 项目存储 +│ │ │ │ ├── avatar.rs # 头像存储 +│ │ │ │ └── cache.rs # 缓存存储 +│ │ │ ├── ffmpeg_cmd.rs # FFmpeg 命令封装 +│ │ │ ├── video_processing.rs # 视频合成逻辑 +│ │ │ ├── api_proxy.rs # Python API 代理 +│ │ │ ├── avatar_cache.rs # 头像视频缓存管理 +│ │ │ └── utils.rs # 通用工具函数 +│ │ ├── binaries/ # 嵌入的 FFmpeg 可执行文件 +│ │ └── Cargo.toml # Rust 依赖配置 +│ └── package.json # NPM 依赖和脚本 +│ +└── docs/ # 开发文档 +``` + +## 常用命令 + +### 后端 (python-api) + +项目使用 `uv` 进行依赖管理,并提供了 `Makefile` 封装常用命令: + +```bash +cd python-api + +# 使用 uv 和 Makefile(推荐) +make dev # 安装开发依赖并配置 pre-commit +make docker-run # 使用 Docker Compose 启动所有服务(db, redis, api, scheduler) +make run # 启动 FastAPI 开发服务器 +make scheduler # 启动 Async Engine Scheduler +make lint # 运行代码检查 (ruff + mypy) +make format # 格式化代码 +make test # 运行所有测试 +make security # 运行安全扫描 (bandit + pip-audit) + +# 手动方式 +# 安装依赖 +python -m venv venv && source venv/bin/activate +pip install -e ".[dev]" + +# 启动 PostgreSQL + Redis(必需) +docker-compose up -d db redis + +# 启动 FastAPI 开发服务器 +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 启动 Async Engine Scheduler(另开终端) +python -m app.scheduler.main + +# 代码质量 +black app/ # 格式化代码(行宽 100) +ruff check app/ # 代码检查 +mypy app/ # 严格类型检查 +bandit -c pyproject.toml -r app/ # 安全扫描 +pip-audit # 依赖漏洞检测 +python scripts/check_config_architecture.py # 检查配置架构一致性 + +# 导出 OpenAPI 文档到前端 +python3 -c " +import logging +logging.disable(logging.WARNING) +from app.main import app +import json +print(json.dumps(app.openapi(), indent=2, ensure_ascii=False)) +" > ../tauri-app/src/api/generated/openapi.json + +# 测试 +pytest # 运行所有测试 +pytest tests/test_script.py -v # 运行单个测试文件 +pytest --cov=app # 覆盖率报告 + +# Docker +docker-compose up -d # 启动所有服务(db, redis, api, scheduler) + +# 端口占用检查 +lsof -i :8080 # 检查 8080 端口占用 +``` + +**可用 Makefile 命令:** + +| 命令 | 用途 | +|------|------| +| `make help` | 显示帮助信息 | +| `make install` | 安装生产依赖(使用 lock 文件)| +| `make dev` | 安装开发依赖并配置 pre-commit | +| `make update-lock` | 更新 requirements.lock | +| `make lint` | 运行代码检查 (ruff + mypy) | +| `make format` | 格式化代码 (black + ruff) | +| `make format-check` | 检查代码格式(不修改)| +| `make test` | 运行测试 | +| `make test-cov` | 运行测试并生成覆盖率报告 | +| `make security` | 运行安全扫描 | +| `make run` | 启动开发服务器 | +| `make scheduler` | 启动 Async Engine Scheduler | +| `make docker-run` | Docker Compose 启动全部服务 | +| `make docker-down` | 停止 Docker 服务 | +| `make clean` | 清理缓存文件 | +| `make ci` | 运行所有 CI 检查 | + +### 前端 (tauri-app) + +```bash +cd tauri-app + +# 安装依赖 +npm install + +# 开发 +npm run dev # 仅启动 Vite(不打开 Tauri 窗口) +npm run tauri dev # 完整 Tauri 桌面开发模式 + +# 构建 +npm run build # 前端生产构建 +npm run tauri build # 打包桌面应用(.dmg/.exe/.AppImage) + +# 代码质量 +npm run lint # ESLint 检查 JS/TS +npm run lint:fix # ESLint 自动修复 +npm run format # Prettier 格式化代码 +npm run stylelint # CSS 检查 + +# 测试 +npm run test # 运行 Vitest +npm run test:coverage # 覆盖率报告 +npm run test:ui # 打开 Vitest UI + +# 类型生成 +npm run gen:api # 从 OpenAPI schema 生成 TypeScript 类型 +``` + +### 数据库迁移 + +项目使用 Alembic 进行数据库迁移: + +```bash +cd python-api + +# 生成新迁移(修改模型后) +alembic revision --autogenerate -m "description" + +# 应用迁移 +alembic upgrade head + +# 回滚迁移 +alembic downgrade -1 +``` + +### 开发提示 + +- **Tauri 调试**: 使用 `npm run tauri dev` 时,Rust 后端日志在终端输出,前端日志在浏览器控制台 +- **本地项目路径**: 项目数据保存在 `~/Documents/Meijiaka/projects/{project_id}/` +- **配置修改**: AI 模型配置 `python-api/config/ai_models.yaml` 支持热重载,无需重启服务 +- **类型同步**: 修改后端 API 后,记得重新导出 OpenAPI 并运行 `npm run gen:api` +- **Async Engine Scheduler**: 系统使用 Slot-Based Scheduler 统一调度所有第三方异步任务: + - `video` - 数字人视频生成(18 slots) + - `avatar_clone` - 形象克隆(2 slots) + - `image` - 图片生成(9 slots) + - `subtitle` - 字幕生成(5 slots) + - `copy` - 文案提取(5 slots) +- **任务状态**: 任务状态唯一真相源为后端 Redis,`taskStore` 不持久化,启动时从后端 `GET /tasks` 查询 +- **项目数据**: 项目元数据和分镜数据通过 IPC 显式写入本地文件,不通过 Zustand persist 持久化 +- **字幕渲染**: 使用 `assjs` 库进行 ASS/SSA 字幕预览渲染,WASM 和 Worker 文件通过 Vite 插件复制到 `public/` 目录,修改资源路径后需要检查插件配置 + +## 开发规范 + +### 后端 (Python) + +- **格式化**: Black (行宽: 100) +- **检查**: Ruff +- **类型**: MyPy (strict 模式) +- **架构**: API → Service → CRUD → Model,禁止跨层调用 +- **数据库**: 始终使用异步 SQLAlchemy,事务在 API 层控制 +- **AI 集成**: 无论使用什么提供者,输出 Schema 必须保持一致,在 Service 层标准化 +- **提示词**: 所有提示词放在 `app/ai/prompts/` 单独文件,不硬编码 +- **配置管理**: 所有配置通过 `from app.config import get_settings` 读取,禁止直接使用 `os.getenv()`,所有配置项必须在 `Settings` 类中定义 + +### 配置管理强制规范 + +**架构层级:** +``` +.env (Layer 1) ──→ Settings (Layer 2) ──→ 服务层 (Layer 3) + ↑ + 唯一配置出口 +``` + +**强制规则:** +- **所有服务**必须使用 `from app.config import get_settings` 读取配置 +- **禁止**在服务层、API 层直接使用 `os.getenv()` 或 `os.environ.get()` +- **所有配置项**必须在 `app/config.py` 的 `Settings` 类中定义 +- **敏感信息**(API Keys、Secrets)必须通过环境变量注入 +- **业务默认值**可以硬编码在 `Settings` 中 + +**添加新配置流程:** +1. 在 `app/config.py` 的 `Settings` 类中添加字段定义 +2. 使用 `Field(default=..., description="...")` 提供默认值和说明 +3. 敏感信息使用 `str | None = None` 类型 +4. 更新 `.env.example` 文档 + +### Rust (Tauri 后端) + +- **格式化**: `rustfmt`(默认配置) +- **检查**: `cargo clippy`(零警告) +- **模块组织**: 命令按领域拆分到 `src/commands/{domain}.rs`,在 `lib.rs` 中注册 +- **存储分层**: 存储逻辑按领域拆分到 `src/storage/{domain}.rs` +- **命令参数**: Tauri IPC 命令必须使用 Args 结构体接收参数: + ```rust + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct SaveProjectMetaArgs { + pub project_id: String, + pub data: serde_json::Value, + } + ``` +- **禁止**: 命令函数直接使用 camelCase 参数名(会产生 `non_snake_case` 警告) + +### 本地数据存储规范(Tauri/Rust) + +**分层架构:** +``` +Layer 1: 页面组件(Pages/Components) — 只操作 Store,禁止直接调用 IPC save +Layer 2: Zustand Store(内存状态) — Immer 不可变更新 +Layer 3: PersistManager(持久化协调) — debounce 批量、flush 强制、错误上报 +Layer 4: API 模块(localStorageApi 等) — 类型安全的 IPC 调用封装 +Layer 5: Rust StorageEngine(文件系统) — sanitize + atomic_write + file_lock +``` + +**强制规范:** +1. **禁止页面组件直接调用 `localProjectApi.saveXxx()`** — 必须通过 Store → PersistManager +2. **禁止 Rust 命令函数直接 `fs::write`** — 必须通过 `StorageEngine::atomic_write_json` +3. **所有 ID 参数必须 `sanitize_id`** — 路径参数白名单校验(`[a-zA-Z0-9_-]+`) +4. **所有 JSON 写操作必须原子化** — 临时文件 + `fs::rename` +5. **RMW 操作必须加锁** — `with_file_lock` 或 Mutex + +**StorageEngine 核心能力:** +- `sanitize_id(id)` — ID 白名单校验,防御路径遍历 +- `sanitize_filename(name)` — 提取纯文件名,拒绝目录组件 +- `atomic_write_json(path, value)` — 先写 `.tmp` 再 rename,防崩溃截断 +- `with_file_lock(path, f)` — 文件锁保护 RMW 操作 +- `read_json(path)` — 安全读取,文件不存在返回 `None`,损坏返回 `Err` + +### 前端 (TypeScript/React) + +- **类型**: 严格 TypeScript 模式 +- **组件**: 函数组件 + Hooks +- **状态管理**: Zustand 管理全局状态,Immer 处理不可变更新 +- **数据获取**: SWR 缓存,自动 localStorage 降级 +- **API 客户端**: 从后端 OpenAPI schema 自动生成类型 +- **命名风格**: camelCase(自动与后端 snake_case 转换) +- **本地存储**: 项目数据通过 Tauri IPC 保存到 `~/Documents/Meijiaka/projects/` + +### 提交规范 + +``` +feat: 新功能 +fix: 修复 +docs: 文档 +refactor: 重构 +test: 测试 +chore: 构建/工具 +``` + +## 环境配置 + +### 后端 (.env) + +```bash +# 数据库 +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka +REDIS_URL=redis://localhost:6379/0 + +# JWT 认证 +SECRET_KEY=your-secret-key-here +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# AI 服务凭证 +VOLCENGINE_API_KEY=your-volcengine-key +VOLCENGINE_CAPTION_APPID=your-caption-appid +VOLCENGINE_CAPTION_TOKEN=your-caption-token +OPENAI_API_KEY=sk-your-openai-key +KLINGAI_ACCESS_KEY=your-kling-access-key +KLINGAI_SECRET_KEY=your-kling-secret-key + +# 七牛云存储(数字人视频持久化) +QINIU_ACCESS_KEY=your-qiniu-access-key +QINIU_SECRET_KEY=your-qiniu-secret-key +QINIU_VIDEO_BUCKET=media-bucket +QINIU_IMAGE_BUCKET=image-bucket + +# CORS 配置 +CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 +``` + +## 服务地址 + +- API: http://localhost:8080/api/v1 +- 文档: http://localhost:8080/docs +- Vite 开发服务器: http://localhost:1420 + +## 关键开发文件 + +| 文件 | 用途 | +|------|------| +| `python-api/app/main.py` | FastAPI 应用入口 | +| `python-api/app/api/v1/*.py` | API 端点定义 | +| `python-api/app/ai/model_router.py` | AI 模型路由和降级 | +| `python-api/app/services/*.py` | 业务逻辑和 AI 响应标准化 | +| `python-api/config/ai_models.yaml` | AI 模型配置 | +| `tauri-app/src/App.tsx` | 主 React 组件 | +| `tauri-app/src/api/client.ts` | 智能路由的 API 客户端 | +| `tauri-app/src/store/projectStore.ts` | 项目状态管理 | +| `tauri-app/src-tauri/src/lib.rs` | Rust 命令注册 | +| `tauri-app/src-tauri/src/commands/project.rs` | 项目存储 IPC 命令 | +| `tauri-app/src-tauri/src/storage/engine.rs` | 核心存储引擎(原子写+校验)| +| `tauri-app/src-tauri/src/video_processing.rs` | FFmpeg 视频合成 | +| `tauri-app/src-tauri/src/avatar_cache.rs` | 头像视频缓存管理 | +| `python-api/app/core/token_manager.py` | API Token 缓存与自动刷新 | +| `python-api/app/config.py` | Pydantic Settings 配置管理 | +| `tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx` | 字幕压制页面(ASS 字幕渲染) | +| `tauri-app/src/hooks/useAssJsRenderer.ts` | assjs 字幕渲染 Hook | +| `tauri-app/src/utils/assGenerator.ts` | ASS 字幕文件生成工具 | + +## 额外开发文档 + +项目 `docs/` 目录包含详细的深度开发文档: + +| 文档 | 主题 | +|------|------| +| `docs/video-generation-flow.md` | 完整视频生成流程说明 | +| `docs/kling-api-dev.md` | KlingAI 数字人视频 API 对接开发文档 | +| `docs/app-update-system.md` | 应用自动更新系统设计 | +| `docs/anytocopy-integration.md` | 版权素材集成说明 | +| `docs/anytocopy-api.md` | 版权素材 API 文档 | +| `docs/volcengine-video-caption-api.md` | 火山引擎字幕 API 对接 | +| `docs/qiniu-kodo-python-sdk-guide.md` | 七牛云存储 SDK 集成指南 | +| `docs/database-design.md` | 数据库设计文档 | +| `docs/unified-async-scheduler.md` | 统一异步调度器设计 | +| `docs/semantic-refactoring-plan.md` | 后端语义重构计划 | +| `docs/migrate-avatars-to-local.md` | 头像数据迁移到本地说明 | + +## 统一术语表(语义治理) + +后端代码已完成语义治理重构,所有开发必须遵守统一术语表,禁止使用废弃别名。 + +整个后端划分为 6 个语义层级,每一层只使用属于该层的术语: + +``` +Layer 6: Presentation (API Schema / 前端适配层) → Segment, Human, Job, Script +Layer 5: Application (API 路由) → Segment, Human, Job, Project +Layer 4: Orchestration (Scheduler / SlotManager) → Job, JobRecord, Slot, Handler +Layer 3: Domain (Service / 业务逻辑) → Segment, Human, VideoComposition, Caption +Layer 2: Adapter (Provider Client) → KlingJob, KlingElement, VolcJob, ProviderTaskId +Layer 1: Infrastructure (DB / Redis / HTTP) → 底层技术术语 +``` + +### 术语对照表 + +| 业务概念 | 官方术语 | 使用层级 | 禁止使用的别名 | +|---------|---------|---------|--------------| +| 视频分镜 | `Segment` | Layer 3-6 | `shot`, `scene_desc` | +| 数字人形象 | `Human` / `Avatar` | Layer 3-6(DB 用 `avatar`,API 用 `human_id`) | `element`, `character` | +| 调度器工作单元 | `Job` | Layer 4 | `task` | +| 供应商侧任务 | `ProviderJob` | Layer 2 | `kling_task`, `volc_task` | +| 供应商任务 ID | `provider_task_id` | Layer 2-4 | `kling_task_id`, `video_task_id`, `image_task_id` | +| 分镜状态 | `SegmentStatus` | Layer 3-4 | 裸字符串 | +| 调度器状态 | `JobStatus` | Layer 4 | 裸字符串 | +| 形象克隆状态 | `AvatarCloneStatus` | Layer 3 | 裸字符串 | +| Kling 原始状态 | `KlingTaskStatus` | **Layer 2 仅限** | 泄漏到 Layer 3+ | + +### 分层禁令 + +1. **API 层 (`app/api/v1/`)**:禁止出现 `element_id`, `kling_task_id`, `shot_type`, `omni` +2. **Scheduler 层 (`app/scheduler/`)**:禁止出现 `task_id`(应为 `job_id`),禁止构造供应商 prompt 语法 +3. **Service 层 (`app/services/`)**:禁止出现 `<<>>` 等供应商专用语法 +4. **Provider 层 (`app/ai/providers/`)**:允许使用 `element_id`, `kling_task_id`, `KlingTaskStatus` + +### 类型禁令 + +- 跨层传递的接口禁止裸用 `dict[str, Any]`。`params`、`result`、`changes` 等字段必须使用 Pydantic 模型或 TypedDict +- 状态字段禁止使用裸字符串,必须使用对应的 `StrEnum` +- CRUD 层 `obj_in` 禁止裸字典,必须使用 `CreateSchema` / `UpdateSchema` diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/anytocopy-api.md b/docs/anytocopy-api.md new file mode 100644 index 0000000..78158db --- /dev/null +++ b/docs/anytocopy-api.md @@ -0,0 +1,357 @@ +# AnyToCopy API 开发文档 + +> 原文档:https://www.anytocopy.com/account/api/docs +> +> 功能:支持 50+ 平台视频文案提取、视频去水印 + +## 概述 + +AnyToCopy API 提供视频/图片文案提取功能,支持抖音、小红书、快手等 50+ 平台。 + +**核心功能**: +- 视频文案提取(语音转文字) +- 视频去水印下载 +- 图片去水印下载 +- 支持 50+ 内容平台 + +--- + +## 基础信息 + +| 项目 | 内容 | +|------|------| +| **Base URL** | `https://api.anytocopy.com/vip/open-api/v1` | +| **协议** | HTTPS | +| **数据格式** | JSON | + +### 鉴权方式 + +在请求头中携带 API Key 和 Secret: + +```http +X-API-Key: your_api_key +X-API-Secret: your_api_secret +``` + +--- + +## 接口列表 + +### 1. 提交视频文案提取任务 + +创建提取任务,返回 `taskId` 用于后续查询。 + +#### 请求 + +| 项目 | 内容 | +|------|------| +| **Method** | POST | +| **Endpoint** | `/video/extract` | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `workUrl` | String | 是 | 作品链接(支持抖音、小红书等) | +| `taskType` | String | 否 | 任务类型,默认 `TEXT`(文案提取) | + +#### curl 示例 + +```bash +curl -X POST 'https://api.anytocopy.com/vip/open-api/v1/video/extract?workUrl=https://v.douyin.com/xxx&taskType=TEXT' \ + -H 'X-API-Key: your_api_key' \ + -H 'X-API-Secret: your_api_secret' +``` + +#### 响应示例 + +**成功响应(HTTP 200)**: +```json +{ + "msg": "任务已提交", + "code": 200, + "data": "2008802706718072832" +} +``` + +**失败响应(并发限制)**: +```json +{ + "msg": "您的并发任务已达上限(5/5),请等待任务完成后再试", + "code": 500 +} +``` + +--- + +### 2. 查询任务状态和结果 + +根据 `taskId` 查询任务进度与提取结果。 + +#### 请求 + +| 项目 | 内容 | +|------|------| +| **Method** | GET | +| **Endpoint** | `/video/query` | + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `taskId` | String | 是 | 任务 ID(提交任务时返回) | + +#### curl 示例 + +```bash +curl -X GET 'https://api.anytocopy.com/vip/open-api/v1/video/query?taskId=2008802706718072832' \ + -H 'X-API-Key: your_api_key' \ + -H 'X-API-Secret: your_api_secret' +``` + +#### 响应示例 + +**任务完成(SUCCESS)**: +```json +{ + "msg": "操作成功", + "code": 200, + "data": { + "taskId": "2008802706718072832", + "title": "小个子女生如何逆袭第一眼大美女", + "content": "#听劝改造[话题]# #如何找到自己的风格", + "videoUrl": "https://sns-video-bd.xhscdn.com/f0370019b934b9b6e_258.mp4", + "videoUrlList": ["https://sns-video-bd.xhscdn.com/stream/79258.mp4"], + "imageUrlList": ["https://ci.xiaohongshu.com/1040g2sg31r0hdqhjnge05q"], + "cover": "https://ci.xiaohongshu.com/1040g2sg31r0hdqhjnge05", + "textContent": "小个子女生真的不要再和别人卷身高上的天赋...", + "platform": "xhs", + "audioUrl": "https://pub-6026ae78487b47e5bd4a5b8a0d9ae5aa.r2.dev/audio.mp3", + "duration": 156.36, + "workType": "video", + "status": "SUCCESS", + "errorMessage": "视频处理成功!", + "createBy": "60227", + "createTime": "2026-01-07 15:27:42" + } +} +``` + +**任务处理中(WAITING)**: +```json +{ + "msg": "操作成功", + "code": 200, + "data": { + "taskId": "2008805155734429696", + "title": "今日摘抄,不知道原创是谁,太多了", + "content": "今日摘抄,不知道原创是谁,太多了,可以在", + "videoUrl": "https://sns-video-qc.xhscdn.com/stream/79/258.mp4", + "videoUrlList": ["https://sns-video-qc.xhscdn.com/stream/79/258.mp4"], + "imageUrlList": ["https://ci.xiaohongshu.com/spectrum/1040g0k031qo"], + "cover": "https://ci.xiaohongshu.com/spectrum/1040g0k031qoj7pr9gm905", + "textContent": "", + "platform": "xhs", + "audioUrl": null, + "duration": null, + "workType": "video", + "status": "WAITING", + "errorMessage": "作品内容提取中...", + "createBy": "60227", + "createTime": "2026-01-07 15:37:26" + } +} +``` + +**任务失败(FAILURE)**: +```json +{ + "msg": "操作成功", + "code": 200, + "data": { + "taskId": "2008805155734429696", + "status": "FAILURE", + "errorMessage": "任务执行失败" + } +} +``` + +--- + +## 响应状态码 + +| 状态码 | 说明 | 场景 | +|--------|------|------| +| 200 | 成功 | 任务创建成功或查询成功 | +| 500 | 失败 | 并发任务已达上限或其他错误 | + +--- + +## 任务状态说明 + +| 状态值 | 说明 | 处理建议 | +|--------|------|----------| +| `WAITING` | 任务等待中或处理中 | 继续轮询查询任务状态 | +| `PROCESSING` | 任务处理中 | 继续轮询查询任务状态 | +| `SUCCESS` | 任务执行成功 | 可获取完整的提取结果数据 | +| `FAILED` / `FAILURE` | 任务执行失败 | 检查 `errorMessage` 字段获取失败原因 | + +--- + +## 响应字段说明 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| `taskId` | String | 任务唯一标识 | +| `title` | String | 作品标题 | +| `content` | String | 作品正文内容 | +| `textContent` | String | 视频语音转文字文案(任务完成后) | +| `videoUrl` | String | 视频下载链接(无水印) | +| `audioUrl` | String | 音频文件链接(任务完成后) | +| `imageUrlList` | Array | 图片链接列表 | +| `cover` | String | 封面图片链接 | +| `platform` | String | 平台标识(xhs、douyin 等) | +| `duration` | Number | 视频时长(秒) | +| `workType` | String | 作品类型(video、image) | +| `status` | String | 任务状态(WAITING、SUCCESS、FAILURE) | +| `errorMessage` | String | 状态描述或错误信息 | +| `createBy` | String | 创建者 ID | +| `createTime` | String | 创建时间 | + +--- + +## 接口使用流程 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ 1. 提交任务 │ --> │ 2. 轮询查询 │ --> │ 3. 处理结果 │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + ▼ ▼ ▼ +POST /video/extract GET /video/query status = SUCCESS +获取 taskId 每 3-5 秒查询一次 获取完整结果 +``` + +### 推荐调用流程 + +1. **提交任务** + - 调用 `POST /video/extract` 接口,传入作品链接 + - 成功后返回 `taskId`,用于后续查询 + +2. **轮询查询** + - 使用返回的 `taskId` 调用 `GET /video/query` 接口 + - 建议每隔 **3-5 秒** 查询一次任务状态 + +3. **处理结果** + - 当 `status` 为 `SUCCESS` 时,获取完整的提取结果(标题、正文、视频、音频等) + - 若为 `FAILURE`,检查 `errorMessage` 了解失败原因 + +--- + +## 最佳实践 + +- **轮询间隔建议**:3-5 秒,避免过于频繁请求 +- **最大轮询次数**:建议设置 60 次上限,避免无限轮询 +- **安全保管**:妥善保管 API Key 和 Secret,不要泄露到客户端 +- **并发限制**:并发任务上限为 5 个,合理安排任务提交 + +--- + +## 支持平台 + +支持 50+ 平台,主要包括: + +| 平台 | 标识 | 说明 | +|------|------|------| +| 小红书 | xhs | 视频、图文 | +| 抖音 | douyin | 视频 | +| 快手 | kuaishou | 视频 | +| ... | ... | 更多平台 | + +--- + +## Python 集成示例 + +```python +import asyncio +import aiohttp + +class AnyToCopyClient: + BASE_URL = "https://api.anytocopy.com/vip/open-api/v1" + + def __init__(self, api_key: str, api_secret: str): + self.api_key = api_key + self.api_secret = api_secret + self.headers = { + "X-API-Key": api_key, + "X-API-Secret": api_secret, + } + + async def submit_task(self, work_url: str, task_type: str = "TEXT") -> dict: + """提交视频文案提取任务""" + url = f"{self.BASE_URL}/video/extract" + params = {"workUrl": work_url, "taskType": task_type} + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=self.headers, params=params) as resp: + return await resp.json() + + async def query_task(self, task_id: str) -> dict: + """查询任务状态和结果""" + url = f"{self.BASE_URL}/video/query" + params = {"taskId": task_id} + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=self.headers, params=params) as resp: + return await resp.json() + + async def extract_video(self, work_url: str, max_retries: int = 60) -> dict: + """完整的视频提取流程(提交 + 轮询)""" + # 1. 提交任务 + submit_result = await self.submit_task(work_url) + if submit_result.get("code") != 200: + raise Exception(f"提交任务失败: {submit_result.get('msg')}") + + task_id = submit_result["data"] + print(f"任务已提交,taskId: {task_id}") + + # 2. 轮询查询 + for i in range(max_retries): + await asyncio.sleep(3) # 每 3 秒查询一次 + + query_result = await self.query_task(task_id) + if query_result.get("code") != 200: + continue + + data = query_result.get("data", {}) + status = data.get("status") + + if status == "SUCCESS": + print(f"任务完成!") + return data + elif status == "FAILURE": + raise Exception(f"任务失败: {data.get('errorMessage')}") + else: + print(f"[{i+1}/{max_retries}] 任务处理中...") + + raise Exception("轮询超时,任务未完成") + + +# 使用示例 +async def main(): + client = AnyToCopyClient( + api_key="your_api_key", + api_secret="your_api_secret" + ) + + try: + result = await client.extract_video("https://v.douyin.com/xxxxx") + print(f"标题: {result['title']}") + print(f"文案: {result['textContent']}") + print(f"视频: {result['videoUrl']}") + except Exception as e: + print(f"错误: {e}") + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/docs/anytocopy-integration.md b/docs/anytocopy-integration.md new file mode 100644 index 0000000..b34f5e3 --- /dev/null +++ b/docs/anytocopy-integration.md @@ -0,0 +1,128 @@ +# AnyToCopy 视频文案提取集成 + +## 功能概述 + +脚本生成 API 现已支持自动识别视频链接并提取文案。 + +- **自动检测**:输入创作主题时自动检测是否为视频链接 +- **智能提取**:支持小红书、抖音、快手等 50+ 平台 +- **无缝集成**:提取的文案自动用于脚本生成 + +## 支持平台 + +| 平台 | 示例链接 | +|------|----------| +| 小红书 | `https://xhslink.com/xxx` | +| 抖音 | `https://v.douyin.com/xxx` | +| 快手 | `https://v.kuaishou.com/xxx` | +| 哔哩哔哩 | `https://b23.tv/xxx` | +| 微博 | `https://weibo.com/xxx` | + +## 使用方式 + +### 1. 普通文案生成(原有功能) + +```json +POST /api/v1/ai/scripts/generate +{ + "topic": "家装验收的5个细节", + "duration": 60, + "script_type": "professional" +} +``` + +### 2. 视频链接提取文案后生成 + +```json +POST /api/v1/ai/scripts/generate +{ + "topic": "https://v.douyin.com/AbC123", + "duration": 60, + "script_type": "professional" +} +``` + +**流程**: +1. 检测输入为视频链接 +2. 调用 AnyToCopy API 提取视频文案 +3. 使用提取的文案作为创作主题生成脚本 + +### 3. 混合输入(链接 + 说明) + +```json +POST /api/v1/ai/scripts/generate +{ + "topic": "参考这个视频的风格 https://v.douyin.com/AbC123,写一个关于装修验收的脚本", + "duration": 60, + "script_type": "professional" +} +``` + +**流程**: +1. 从文本中提取视频链接 +2. 提取视频文案 +3. 将提取的文案与原始说明结合生成脚本 + +## 流式生成(SSE) + +视频提取过程会显示在进度中: + +``` +data: {"type": "analyzing", "progress": 5, "message": "检测到视频链接,正在提取文案..."} + +data: {"type": "analyzing", "progress": 10, "message": "视频文案提取成功,共 1200 字符"} + +data: {"type": "generating", "progress": 15, "message": "正在创作脚本..."} +... +``` + +## 配置 + +在 `.env` 文件中配置 AnyToCopy API: + +```bash +# AnyToCopy 视频文案提取服务 +ANYTOCOPY_API_KEY=your-api-key +ANYTOCOPY_API_SECRET=your-api-secret +ANYTOCOPY_BASE_URL=https://api.anytocopy.com/vip/open-api/v1 +``` + +## 注意事项 + +1. **API Key**:需要从 AnyToCopy 官网获取 API Key +2. **并发限制**:AnyToCopy 限制并发任务数为 5 +3. **提取时间**:视频文案提取通常需要 10-30 秒 +4. **失败处理**:如果提取失败,会自动使用原始输入继续生成脚本 + +## 代码集成 + +### 服务层 + +```python +from app.services.anytocopy_service import get_anytocopy_service + +anytocopy = get_anytocopy_service() +result = await anytocopy.extract_text_from_input("https://v.douyin.com/xxx") + +if result["is_video_url"]: + extracted_text = result["extracted_text"] + # 使用提取的文案 +``` + +### 独立使用 AnyToCopy 服务 + +```python +from app.services.anytocopy_service import AnyToCopyService + +service = AnyToCopyService({ + "api_key": "your-key", + "api_secret": "your-secret", +}) + +# 提交任务 +result = await service.submit_task("https://v.douyin.com/xxx") +task_id = result["data"] + +# 查询结果 +query_result = await service.query_task(task_id) +``` diff --git a/docs/app-update-system.md b/docs/app-update-system.md new file mode 100644 index 0000000..b99c7ab --- /dev/null +++ b/docs/app-update-system.md @@ -0,0 +1,2402 @@ +# 应用自动更新系统开发文档 + +## 概述 + +本文档详细说明美家卡智影应用自动更新系统的完整实现方案,采用自建更新服务器 + 七牛云存储的架构,解决国内访问 GitHub 不稳定的问题。 + +**架构特点**: +- 轻量云账号 + 全本地业务数据 +- 七牛云存储更新包,稳定访问 +- FastAPI 提供更新检查和下载接口 +- Tauri 应用通过 API 获取更新并安装 +- 支持强制更新、下载统计等功能 + +--- + +## 一、架构设计 + +### 1.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 桌面应用 │ +│ ┌──────────────────────┐ ┌──────────────────────────┐ │ +│ │ React 前端 │────────▶│ Rust 后端 │ │ +│ │ - 更新对话框 │ IPC │ - 检查/下载/安装 │ │ +│ └──────────────────────┘ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ │ + │ HTTP │ + ▼ │ +┌─────────────────────────────────────────────────────────────────┐ +│ 后端服务层 │ +│ ┌──────────────────────┐ ┌──────────────────────────┐ │ +│ │ FastAPI 后端 │────────▶│ PostgreSQL 数据库 │ │ +│ │ - 更新 API │ │ - 版本/包/下载日志 │ │ +│ └──────────────────────┘ └──────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ 七牛云存储 │ │ +│ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 更新流程 + +应用启动 → 检查更新 → 显示对话框 → 下载安装包 → 安装 → 重启应用 + +--- + +## 二、数据库设计 + +### 2.1 数据表结构 + +#### 2.1.1 app_releases - 版本发布记录 + +| 字段 | 类型 | 说明 | 约束 | +|------|------|------|------| +| id | SERIAL | 主键 | PRIMARY KEY | +| version | VARCHAR(20) | 版本号 (语义化版本) | NOT NULL, UNIQUE | +| release_date | TIMESTAMP | 发布时间 | NOT NULL | +| notes | TEXT | 更新说明 | NOT NULL | +| mandatory | BOOLEAN | 是否强制更新 | DEFAULT FALSE | +| created_at | TIMESTAMP | 创建时间 | DEFAULT NOW() | + +**示例数据**: +```sql +INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES +('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE), +('0.1.1', '2026-04-14 10:00:00', '新功能:视频字幕压制\n修复:导出问题', FALSE), +('0.2.0', '2026-04-20 10:00:00', '新增:批量导出功能\n优化:性能提升 30%', FALSE); +``` + +#### 2.1.2 release_packages - 平台包信息 + +| 字段 | 类型 | 说明 | 约束 | +|------|------|------|------| +| id | SERIAL | 主键 | PRIMARY KEY | +| release_id | INTEGER | 关联版本发布 | FOREIGN KEY → app_releases.id | +| platform | VARCHAR(20) | 操作系统平台 | NOT NULL | +| architecture | VARCHAR(20) | CPU 架构 | NOT NULL | +| filename | VARCHAR(255) | 文件名 | NOT NULL | +| file_url | VARCHAR(500) | 七牛云下载 URL | NOT NULL | +| file_size | BIGINT | 文件大小(字节) | NOT NULL | +| file_hash | VARCHAR(64) | SHA256 哈希 | NOT NULL | +| download_count | INTEGER | 下载次数 | DEFAULT 0 | +| created_at | TIMESTAMP | 创建时间 | DEFAULT NOW() | + +**平台枚举值**: +- `darwin`: macOS +- `windows`: Windows +- `linux`: Linux + +**架构枚举值**: +- `x86_64`: 64位 Intel/AMD +- `arm64`: ARM64 (Apple Silicon) + +**示例数据**: +```sql +INSERT INTO release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES +(2, 'darwin', 'x86_64', 'meijiaka_0.1.1_darwin_x86_64.dmg', + 'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_darwin_x86_64.dmg', + 102400000, 'sha256:abc123...'), + +(2, 'windows', 'x86_64', 'meijiaka_0.1.1_windows_x86_64-setup.exe', + 'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_windows_x86_64-setup.exe', + 115343360, 'sha256:def456...'), + +(2, 'linux', 'amd64', 'meijiaka_0.1.1_linux_amd64.AppImage', + 'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_linux_amd64.AppImage', + 104857600, 'sha256:ghi789...'); +``` + +#### 2.1.3 update_downloads - 更新下载日志 + +| 字段 | 类型 | 说明 | 约束 | +|------| | | | +| id | SERIAL | 主键 | PRIMARY KEY | +| release_id | INTEGER | 关联版本发布 | FOREIGN KEY → app_releases.id | +| platform | VARCHAR(20) | 下载平台 | NOT NULL | +| app_version | VARCHAR(20) | 应用当前版本 | NOT NULL | +| user_id | INTEGER | 用户 ID (可选) | FOREIGN KEY → users.id | +| download_at | TIMESTAMP | 下载时间 | DEFAULT NOW() | + +### 2.2 索引设计 + +```sql +-- 快速查询最新版本 +CREATE INDEX idx_releases_release_date ON app_releases(release_date DESC); + +-- 平台包复合索引 +CREATE INDEX idx_packages_platform_arch ON release_packages(platform, architecture); + +-- 下载统计 +CREATE INDEX idx_downloads_release_id ON update_downloads(release_id); +``` + +### 2.3 初始化脚本 + +文件位置:`python-api/scripts/init_update_tables.sql` + +```sql +-- 创建版本发布记录表 +CREATE TABLE IF NOT EXISTS app_releases ( + id SERIAL PRIMARY KEY, + version VARCHAR(20) NOT NULL UNIQUE, + release_date TIMESTAMP NOT NULL, + notes TEXT NOT NULL, + mandatory BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 创建平台包信息表 +CREATE TABLE IF NOT EXISTS release_packages ( + id SERIAL PRIMARY KEY, + release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE, + platform VARCHAR(20) NOT NULL, + architecture VARCHAR(20) NOT NULL, + filename VARCHAR(255) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + file_hash VARCHAR(64) NOT NULL, + download_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 创建更新下载日志表 +CREATE TABLE IF NOT EXISTS update_downloads ( + id SERIAL PRIMARY KEY, + release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE, + platform VARCHAR(20) NOT NULL, + app_version VARCHAR(20) NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + download_at TIMESTAMP DEFAULT NOW() +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_releases_version ON app_releases(version); +CREATE INDEX IF NOT EXISTS idx_releases_release_date ON app_releases(release_date DESC); +CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON release_packages(platform, architecture); +CREATE INDEX IF NOT EXISTS idx_packages_release_id ON release_packages(release_id); +CREATE INDEX IF NOT EXISTS idx_downloads_release_id ON update_downloads(release_id); +CREATE INDEX IF NOT EXISTS idx_downloads_download_at ON update_downloads(download_at); + +-- 插入初始版本(可选) +INSERT INTO app_releases (version, release_date, notes, mandatory) +VALUES ('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE) +ON CONFLICT (version) DO NOTHING; +``` + +--- + +## 三、后端 API 设计 + +### 3.1 API 端点列表 + +| 方法 | 路径 | 说明 | 认证 | +|------|------|------|------| +| POST | `/api/v1/update/check` | 检查应用更新 | 无需认证 | +| GET | `/api/v1/update/download/{version}/{platform}` | 获取下载 URL 并记录 | 无需认证 | +| POST | `/api/v1/update/releases` | 创建新版本发布 | 需要管理员认证 | +| GET | `/api/v1/update/releases` | 获取所有版本列表 | 需要管理员认证 | +| DELETE | `/api/v1/update/releases/{version}` | 删除版本发布 | 需要管理员认证 | + +### 3.2 数据模型 + +#### 3.2.1 SQLAlchemy 模型 + +文件位置:`python-api/app/models/update.py` + +```python +from datetime import datetime +from typing import Optional +from sqlalchemy import DateTime, Boolean, Integer, String, Text, BigInteger, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .base import Base + +class AppRelease(Base): + """应用版本发布记录""" + __tablename__ = "app_releases" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + version: Mapped[str] = mapped_column(String(20), unique=True, index=True) + release_date: Mapped[datetime] = mapped_column(DateTime, nullable=False) + notes: Mapped[str] = mapped_column(Text, nullable=False) + mandatory: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # 关系 + packages: Mapped[list["ReleasePackage"]] = relationship( + "ReleasePackage", + back_populates="release", + cascade="all, delete-orphan" + ) + downloads: Mapped[list["UpdateDownload"]] = relationship( + "UpdateDownload", + back_populates="release", + cascade="all, delete-orphan" + ) + +class ReleasePackage(Base): + """平台包信息""" + __tablename__ = "release_packages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + release_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("app_releases.id", ondelete="CASCADE"), + nullable=False + ) + platform: Mapped[str] = mapped_column(String(20), nullable=False) + architecture: Mapped[str] = mapped_column(String(20), nullable=False) + filename: Mapped[str] = mapped_column(String(255), nullable=False) + file_url: Mapped[str] = mapped_column(String(500), nullable=False) + file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) + file_hash: Mapped[str] = mapped_column(String(64), nullable=False) + download_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # 关系 + release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages") + +class UpdateDownload(Base): + """更新下载日志""" + __tablename__ = "update_downloads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + release_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("app_releases.id", ondelete="CASCADE"), + nullable=False + ) + platform: Mapped[str] = mapped_column(String(20), nullable=False) + app_version: Mapped[str] = mapped_column(String(20), nullable=False) + user_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="SET NULL") + ) + download_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # 关系 + release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="downloads") +``` + +#### 3.2.2 Pydantic Schemas + +文件位置:`python-api/app/schemas/update.py` + +```python +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + +class UpdateCheckRequest(BaseModel): + """更新检查请求""" + current_version: str = Field(..., description="当前应用版本") + platform: str = Field(..., description="操作系统平台: darwin, windows, linux") + architecture: Optional[str] = Field(None, description="CPU架构: x86_64, arm64") + +class PackageInfo(BaseModel): + """包信息""" + platform: str + architecture: str + file_url: str + file_size: int + file_hash: str + +class UpdateInfoResponse(BaseModel): + """更新信息响应""" + version: str + release_date: datetime + notes: str + mandatory: bool + packages: list[PackageInfo] + +class PackageCreate(BaseModel): + """包创建信息""" + platform: str + architecture: str + file_url: str + file_size: int + file_hash: str + +class ReleaseCreate(BaseModel): + """版本发布创建请求""" + version: str + notes: str + mandatory: bool = False + packages: list[PackageCreate] + +class ReleaseResponse(BaseModel): + """版本发布响应""" + id: int + version: str + release_date: datetime + notes: str + mandatory: bool + created_at: datetime + packages: list[PackageInfo] + +class DownloadResponse(BaseModel): + """下载信息响应""" + download_url: str + file_size: int + file_hash: str +``` + +### 3.3 API 路由实现 + +文件位置:`python-api/app/api/v1/update.py` + +```python +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from typing import Optional + +from ...deps import get_db +from ...models.update import AppRelease, ReleasePackage, UpdateDownload +from ...schemas.update import ( + UpdateCheckRequest, + UpdateInfoResponse, + ReleaseCreate, + ReleaseResponse, + DownloadResponse, + PackageInfo, + PackageCreate +) + +router = APIRouter() + +@router.post("/check", response_model=UpdateInfoResponse, status_code=status.HTTP_200_OK) +async def check_update( + request: UpdateCheckRequest, + db: AsyncSession = Depends(get_db) +): + """ + 检查应用更新 + + Args: + request: 包含当前版本、平台信息的请求 + + Returns: + 最新的版本信息,如果已是最新版本则返回 204 No Content + """ + # 查询最新版本 + result = await db.execute( + select(AppRelease) + .order_by(AppRelease.release_date.desc()) + .limit(1) + ) + latest_release = result.scalar_one_or_none() + + if not latest_release: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No releases found" + ) + + # 如果已是最新版本 + if latest_release.version == request.current_version: + raise HTTPException( + status_code=status.HTTP_204_NO_CONTENT, + detail="Already up to date" + ) + + # 确定架构(如果未提供) + arch = request.architecture or _get_default_architecture(request.platform) + + # 查询对应平台的包 + result = await db.execute( + select(ReleasePackage).where( + and_( + ReleasePackage.release_id == latest_release.id, + ReleasePackage.platform == request.platform, + ReleasePackage.architecture == arch + ) + ) + ) + packages = result.scalars().all() + + if not packages: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No package found for platform {request.platform} {arch}" + ) + + # 构建响应 + return UpdateInfoResponse( + version=latest_release.version, + release_date=latest_release.release_date, + notes=latest_release.notes, + mandatory=latest_release.mandatory, + packages=[ + PackageInfo( + platform=p.platform, + architecture=p.architecture, + file_url=p.file_url, + file_size=p.file_size, + file_hash=p.file_hash + ) + for p in packages + ] + ) + +@router.get("/download/{version}/{platform}", response_model=DownloadResponse) +async def get_download_url( + version: str, + platform: str, + db: AsyncSession = Depends(get_db) +): + """ + 获取下载 URL 并记录下载日志 + + Args: + version: 目标版本 + platform: 平台类型 + + Returns: + 包含下载 URL、文件大小和哈希的响应 + """ + # 查询版本信息 + result = await db.execute( + select(AppRelease).where(AppRelease.version == version) + ) + release = result.scalar_one_or_none() + + if not release: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Release not found" + ) + + # 查询对应平台的包 + result = await db.execute( + select(ReleasePackage).where( + and_( + ReleasePackage.release_id == release.id, + ReleasePackage.platform == platform + ) + ) + ) + package = result.scalar_one_or_none() + + if not package: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Package not found for platform {platform}" + ) + + # 增加下载计数 + package.download_count += 1 + + # 记录下载日志(可选,不阻塞主流程) + download_log = UpdateDownload( + release_id=release.id, + platform=platform, + app_version=version + ) + db.add(download_log) + + await db.commit() + + return DownloadResponse( + download_url=package.file_url, + file_size=package.file_size, + file_hash=package.file_hash + ) + +@router.post("/releases", response_model=ReleaseResponse) +async def create_release( + release: ReleaseCreate, + db: AsyncSession = Depends(get_db) +): + """ + 创建新版本发布 + + Args: + release: 版本发布信息 + + Returns: + 创建的版本发布信息 + """ + # 检查版本是否已存在 + result = await db.execute( + select(AppRelease).where(AppRelease.version == release.version) + ) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Version already exists" + ) + + # 创建发布记录 + new_release = AppRelease( + version=release.version, + release_date=datetime.utcnow(), + notes=release.notes, + mandatory=release.mandatory + ) + db.add(new_release) + await db.flush() # 获取 ID + + # 创建包记录 + for pkg in release.packages: + package = ReleasePackage( + release_id=new_release.id, + platform=pkg.platform, + architecture=pkg.architecture, + filename=pkg.file_url.split("/")[-1], + file_url=pkg.file_url, + file_size=pkg.file_size, + file_hash=pkg.file_hash + ) + db.add(package) + + await db.commit() + + # 构建响应 + return ReleaseResponse( + id=new_release.id, + version=new_release.version, + release_date=new_release.release_date, + notes=new_release.notes, + mandatory=new_release.mandatory, + created_at=new_release.created_at, + packages=[ + PackageInfo( + platform=pkg.platform, + architecture=pkg.architecture, + file_url=pkg.file_url, + file_size=pkg.file_size, + file_hash=pkg.file_hash + ) + for pkg in release.packages + ] + ) + +@router.get("/releases", response_model=list[ReleaseResponse]) +async def list_releases( + db: AsyncSession = Depends(get_db) +): + """ + 获取所有版本发布列表 + + Returns: + 所有版本发布信息列表 + """ + result = await db.execute( + select(AppRelease).order_by(AppRelease.release_date.desc()) + ) + releases = result.scalars().all() + + responses = [] + for release in releases: + # 获取包信息 + result = await db.execute( + select(ReleasePackage).where( + ReleasePackage.release_id == release.id + ) + ) + packages = result.scalars().all() + + responses.append(ReleaseResponse( + id=release.id, + version=release.version, + release_date=release.release_date, + notes=release.notes, + mandatory=release.mandatory, + created_at=release.created_at, + packages=[ + PackageInfo( + platform=p.platform, + architecture=p.architecture, + file_url=p.file_url, + file_size=p.file_size, + file_hash=p.file_hash + ) + for p in packages + ] + )) + + return responses + +@router.delete("/releases/{version}") +async def delete_release( + version: str, + db: AsyncSession = Depends(get_db) +): + """ + 删除版本发布 + + Args: + version: 要删除的版本号 + + Returns: + 操作结果 + """ + result = await db.execute( + select(AppRelease).where(AppRelease.version == version) + ) + release = result.scalar_one_or_none() + + if not release: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Release not found" + ) + + await db.delete(release) + await db.commit() + + return {"status": "success", "message": f"Release {version} deleted"} + +# 辅助函数 +def _get_default_architecture(platform: str) -> str: + """获取系统架构默认值""" + if platform == "darwin": + return "universal" # macOS 默认 universal + elif platform == "linux": + return "amd64" # Linux 默认 amd64 + else: + return "x86_64" # Windows 默认 x86_64 +``` + +### 3.4 注册路由 + +在 `python-api/app/main.py` 中注册: + +```python +from app.api.v1.update import router as update_router + +app.include_router(update_router, prefix="/api/v1/update", tags=["更新"]) +``` + +--- + +## 四、Rust 后端更新逻辑 + +### 4.1 模块结构 + +``` +tauri-app/src-tauri/src/ +├── updater.rs # 更新模块主文件 +├── lib.rs # 命令注册 +└── Cargo.toml # 依赖配置 +``` + +### 4.2 依赖配置 + +在 `tauri-app/src-tauri/Cargo.toml` 中添加: + +```toml +[dependencies] +# 现有依赖... +sha2 = "0.10" # SHA256 哈希计算 +tokio = { version = "1", features = ["fs", "io-util"] } # 异步 I/O +dirs = "5" # 获取系统目录路径 +``` + +### 4.3 更新模块实现 + +文件位置:`tauri-app/src-tauri/src/updater.rs` + +```rust +use tauri::{AppHandle, Emitter}; +use reqwest::{self, Client}; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; +use sha2::{Sha256, Digest}; + +// ============ 数据结构 ============ + +#[derive(Debug, Serialize, Deserialize)] +pub struct PackageInfo { + pub platform: String, + pub architecture: String, + pub file_url: String, + pub file_size: u64, + pub file_hash: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateInfo { + pub version: String, + pub release_date: String, + pub notes: String, + pub mandatory: bool, + pub packages: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DownloadProgress { + pub downloaded: u64, + pub total: u64, + pub percentage: f32, +} + +// ============ Tauri 命令 ============ + +/// 检查应用更新 +#[tauri::command] +pub async fn check_update( + app: AppHandle, + current_version: String, +) -> Result, String> { + // 获取平台信息 + let platform = std::env::consts::OS; + let platform_name = match platform { + "macos" => "darwin", + "windows" => "windows", + "linux" => "linux", + _ => return Err(format!("Unsupported platform: {}", platform)), + }; + + // 获取架构信息 + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + return Err("Unsupported architecture".to_string()); + }; + + // 调用 FastAPI 检查更新 + let client = Client::new(); + let api_url = "http://localhost:8080"; // 从配置读取 + + let url = format!( + "{}/api/v1/update/check", + api_url + ); + + let response = client + .post(&url) + .json(&serde_json::json!({ + "current_version": current_version, + "platform": platform_name, + "architecture": arch + })) + .send() + .await + .map_err(|e| format!("Failed to check update: {}", e))?; + + if response.status() == 204 { + return Ok(None); // 已是最新版本 + } + + let update_info: UpdateInfo = response + .json() + .await + .map_err(|e| format!("Failed to parse update info: {}", e))?; + + Ok(Some(update_info)) +} + +/// 下载更新包 +#[tauri::command] +pub async fn download_update( + app: AppHandle, + url: String, + file_hash: String, + expected_size: u64, +) -> Result { + // 创建更新目录 + let cache_dir = app.path().app_cache_dir() + .map_err(|e| format!("Failed to get cache dir: {}", e))?; + + let updates_dir = cache_dir.join("updates"); + fs::create_dir_all(&updates_dir) + .map_err(|e| format!("Failed to create updates dir: {}", e))?; + + // 从 URL 提取文件名 + let filename = url.split('/').last() + .unwrap_or("update.pkg") + .to_string(); + + let save_path = updates_dir.join(&filename); + + // 检查是否已下载并验证 + if save_path.exists() { + if verify_file_hash(&save_path, &file_hash).await { + return Ok(save_path.to_string_lossy().to_string()); + } + // 哈希不匹配,删除重新下载 + fs::remove_file(&save_path)?; + } + + // 发起下载请求 + let client = Client::new(); + let mut response = client + .get(&url) + .send() + .await + .map_err(|e| format!("Failed to download: {}", e))?; + + let total_size = expected_size; + let mut downloaded = 0u64; + let mut file = File::create(&save_path) + .map_err(|e| format!("Failed to create file: {}", e))?; + + // 流式下载并报告进度 + while let Some(chunk_result) = response.chunk().await { + let chunk = chunk_result + .map_err(|e| format!("Download error: {}", e))?; + + file.write_all(&chunk) + .map_err(|e| format!("Write error: {}", e))?; + + downloaded += chunk.len() as u64; + + // 发送进度事件 + let percentage = if total_size > 0 { + (downloaded as f32 / total_size as f32) * 100.0 + } else { + 0.0 + }; + + let _ = app.emit("download-progress", DownloadProgress { + downloaded, + total: total_size, + percentage, + }); + } + + // 验证文件哈希 + if !verify_file_hash(&save_path, &file_hash).await { + fs::remove_file(&save_path)?; + return Err("File hash verification failed".to_string()); + } + + Ok(save_path.to_string_lossy().to_string()) +} + +/// 安装更新包 +#[tauri::command] +pub async fn install_update( + app: AppHandle, + package_path: String, +) -> Result<(), String> { + let package_path = PathBuf::from(package_path); + + #[cfg(target_os = "macos")] + { + install_macos(app, package_path).await?; + } + + #[cfg(target_os = "windows")] + { + install_windows(app, package_path).await?; + } + + #[cfg(target_os = "linux")] + { + install_linux(app, package_path).await?; + } + + Ok(()) +} + +// ============ 平台特定实现 ============ + +#[cfg(target_os = "macos")] +async fn install_macos(app: AppHandle, package_path: PathBuf) -> Result<(), String> { + use std::process::Command; + use std::thread; + use std::time::Duration; + + // macOS: 使用 hdiutil 挂载 dmg 并复制应用到 ~/Applications + let mount_dir = PathBuf::from("/tmp/meijiaka_mount"); + + // 确保挂载目录不存在 + if mount_dir.exists() { + let _ = Command::new("hdiutil") + .args(["detach", mount_dir.to_str().unwrap(), "-force"]) + .status(); + } + + // 挂载 DMG + let output = Command::new("hdiutil") + .args([ + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mount_dir.to_str().unwrap(), + package_path.to_str().unwrap(), + ]) + .output() + .map_err(|e| format!("Failed to mount DMG: {}", e))?; + + if !output.status.success() { + return Err(format!("Failed to mount DMG: {}", + String::from_utf8_loss(output.stderr.as_slice()))); + } + + // 查找应用文件 + let app_path = find_app_in_dmg(&mount_dir)?; + let app_name = app_path.file_name() + .ok_or("Invalid app path")? + .to_string_lossy() + .to_string(); + + // 用户目录 Applications + let dest_dir = dirs::home_dir() + .ok_or("Failed to get home directory")? + .join("Applications"); + + fs::create_dir_all(&dest_dir) + .map_err(|e| format!("Failed to create Applications dir: {}", e))?; + + let dest_path = dest_dir.join(&app_name); + + // 删除旧版本 + if dest_path.exists() { + let _ = Command::new("rm") + .args(["-rf", dest_path.to_str().unwrap()]) + .status(); + } + + // 复制应用 + let output = Command::new("cp") + .args(["-R", app_path.to_str().unwrap(), dest_dir.to_str().unwrap()]) + .output() + .map_err(|e| format!("Failed to copy app: {}", e))?; + + if !output.status.success() { + return Err("Failed to copy app".to_string()); + } + + // 卸载 DMG + let _ = Command::new("hdiutil") + .args(["detach", mount_dir.to_str().unwrap(), "-force"]) + .status(); + + // 延迟启动新版本 + thread::spawn(move || { + thread::sleep(Duration::from_secs(2)); + let _ = Command::new("open") + .args(["-a", &app_name]) + .status(); + }); + + // 退出当前应用 + app.exit(0); + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn find_app_in_dmg(mount_dir: &PathBuf) -> Result { + use std::fs; + + let entries = fs::read_dir(mount_dir) + .map_err(|e| format!("Failed to read mount dir: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + + // 查找 .app 目录 + if path.extension().and_then(|s| s.to_str()) == Some("app") { + return Ok(path); + } + + // 递归查找 + if path.is_dir() { + if let Ok(app_path) = find_app_in_dmg(&path) { + return Ok(app_path); + } + } + } + + Err("App not found in DMG".to_string()) +} + +#[cfg(target_os = "windows")] +async fn install_windows(app: AppHandle, package_path: PathBuf) -> Result<(), String> { + use std::process::Command; + use std::thread; + use std::time::Duration; + + // Windows: 使用 NSIS 安装包 + let _ = Command::new(package_path.to_str().unwrap()) + .args(["/S"]) // 静默安装 + .spawn() + .map_err(|e| format!("Failed to start installer: {}", e))?; + + // 延迟退出,让安装程序完成 + thread::sleep(Duration::from_secs(2)); + + app.exit(0); + + Ok(()) +} + +#[cfg(target_os = "linux")] +async fn install_linux(app: AppHandle, package_path: PathBuf) -> Result<(), String> { + use std::process::Command; + use std::thread; + use std::time::Duration; + + // Linux: 提取 AppImage 并设置执行权限 + let home_dir = dirs::home_dir() + .ok_or("Failed to get home directory")?; + + let app_dir = home_dir.join("Applications"); + fs::create_dir_all(&app_dir)?; + + let filename = package_path.file_name() + .ok_or("Invalid package path")?; + + let dest_path = app_dir.join(filename); + + // 复制文件 + fs::copy(&package_path, &dest_path)?; + + // 设置执行权限 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&dest_path)?.permissions(); + perms.set_mode(perms.mode() | 0o111); + fs::set_permissions(&dest_path, perms)?; + } + + // 启动新版本 + thread::spawn(move || { + thread::sleep(Duration::from_secs(2)); + let _ = Command::new(dest_path.to_str().unwrap()) + .status(); + }); + + app.exit(0); + + Ok(()) +} + +// ============ 工具函数 ============ + +/// 验证文件 SHA256 哈希 +async fn verify_file_hash(file_path: &PathBuf, expected_hash: &str) -> bool { + use tokio::fs; + + let file = fs::File::open(file_path).await; + if file.is_err() { + return false; + } + + let mut file = file.unwrap(); + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + + loop { + let n = file.read(&mut buffer).await.unwrap_or(0); + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + let actual_hash = format!("sha256:{:x}", hasher.finalize()); + actual_hash == expected_hash +} +``` + +**注意**:上述示例代码混合使用了同步和异步 I/O。在生产环境中,建议统一使用异步 I/O 以获得更好的性能。统一后的代码需要使用 `tokio::fs` 替代 `std::fs`,并使用 `tokio::process::Command` 替代 `std::process::Command`。 + +### 4.4 注册命令 + +在 `tauri-app/src-tauri/src/lib.rs` 中注册命令: + +```rust +mod updater; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![ + // ... 其他命令 + updater::check_update, + updater::download_update, + updater::install_update + ]) + .setup(|app| { + #[cfg(debug_assertions)] + { + let window = app.get_webview_window("main").unwrap(); + window.open_devtools(); + } + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +--- + +## 五、前端实现 + +### 5.1 更新状态管理 + +文件位置:`tauri-app/src/store/updateStore.ts` + +```typescript +import { create } from 'zustand'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +interface UpdatePackage { + platform: string; + architecture: string; + file_url: string; + file_size: number; + file_hash: string; +} + +interface UpdateInfo { + version: string; + release_date: string; + notes: string; + mandatory: boolean; + packages: UpdatePackage[]; +} + +interface DownloadProgress { + downloaded: number; + total: number; + percentage: number; +} + +interface UpdateState { + updateAvailable: boolean; + updateInfo: UpdateInfo | null; + downloadProgress: DownloadProgress; + downloading: boolean; + checking: boolean; + installing: boolean; + downloadedPath: string | null; + + checkUpdate: (currentVersion: string) => Promise; + downloadUpdate: () => Promise; + installUpdate: () => Promise; + dismissUpdate: () => void; +} + +export const useUpdateStore = create((set, get) => ({ + updateAvailable: false, + updateInfo: null, + downloadProgress: { downloaded: 0, total: 0, percentage: 0 }, + downloading: false, + checking: false, + installing: false, + downloadedPath: null, + + checkUpdate: async (currentVersion: string) => { + set({ checking: true }); + try { + const updateInfo = await invoke('check_update', { + currentVersion, + }); + + if (updateInfo) { + set({ updateAvailable: true, updateInfo }); + } else { + set({ updateAvailable: false, updateInfo: null }); + } + } catch (error) { + console.error('Check update failed:', error); + set({ updateAvailable: false, updateInfo: null }); + } finally { + set({ checking: false }); + } + }, + + downloadUpdate: async () => { + const { updateInfo } = get(); + if (!updateInfo) { + throw new Error('No update available'); + } + + // 获取当前平台的包 + const platform = process.platform === 'darwin' ? 'darwin' : + process.platform === 'win32' ? 'windows' : 'linux'; + + const arch = process.arch === 'arm64' ? 'arm64' : 'x86_64'; + + const pkg = updateInfo.packages.find( + p => p.platform === platform && p.architecture === arch + ); + + if (!pkg) { + throw new Error(`No package found for ${platform} ${arch}`); + } + + set({ downloading: true, downloadProgress: { downloaded: 0, total: pkg.file_size, percentage: 0 } }); + + try { + // 监听下载进度 + const unlisten = await listen('download-progress', (event) => { + set({ downloadProgress: event.payload }); + }); + + const downloadedPath = await invoke('download_update', { + url: pkg.file_url, + fileHash: pkg.file_hash, + expectedSize: pkg.file_size, + }); + + await unlisten(); + set({ downloadedPath }); + } catch (error) { + console.error('Download failed:', error); + throw error; + } finally { + set({ downloading: false }); + } + }, + + installUpdate: async () => { + const { downloadedPath } = get(); + if (!downloadedPath) { + throw new Error('No downloaded package'); + } + + set({ installing: true }); + try { + await invoke('install_update', { + packagePath: downloadedPath, + }); + // 安装后应用会重启,这里不会执行 + } catch (error) { + console.error('Install failed:', error); + throw error; + } finally { + set({ installing: false }); + } + }, + + dismissUpdate: () => { + set({ updateAvailable: false, updateInfo: null }); + }, +})); +``` + +### 5.2 更新对话框组件 + +文件位置:`tauri-app/src/components/UpdateDialog.tsx` + +```tsx +import { useEffect } from 'react'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { useUpdateStore } from '@/store/updateStore'; + +export function UpdateDialog() { + const { + updateAvailable, + updateInfo, + downloadProgress, + downloading, + checking, + installing, + checkUpdate, + downloadUpdate, + installUpdate, + dismissUpdate, + } = useUpdateStore(); + + // 当前应用版本(从 package.json 或构建时注入) + const CURRENT_VERSION = import.meta.env.VITE_APP_VERSION || '0.1.0'; + + useEffect(() => { + // 启动时检查更新 + checkUpdate(CURRENT_VERSION); + }, []); + + if (!updateAvailable) return null; + + const isMandatory = updateInfo?.mandatory; + + return ( + !open && !isMandatory && dismissUpdate()}> + + + {checking ? '检查更新...' : + downloading ? '下载更新中...' : + installing ? '安装更新...' : + `发现新版本 ${updateInfo?.version}`} + + +
+ {!checking && updateInfo && ( + <> +
+ {updateInfo.notes} +
+ + {downloading && ( +
+ +
+ {Math.round(downloadProgress.percentage)}% + ({(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} MB / + {(downloadProgress.total / 1024 / 1024).toFixed(1)} MB) +
+
+ )} + +
+ {!downloading && !installing && ( + <> + {!isMandatory && ( + + )} + + + )} + + {!checking && !downloading && installing && ( + + )} + + {downloading && ( + + )} +
+ + {isMandatory && !installing && !downloading && ( +
+ 此版本为强制更新,必须安装后才能继续使用 +
+ )} + + )} +
+
+
+ ); +} +``` + +### 5.3 设置页面集成 + +在设置页面添加手动检查更新按钮: + +```tsx +import { useUpdateStore } from '@/store/updateStore'; +import { Button } from '@/components/ui/button'; + +function SettingsPage() { + const { checkUpdate, updateAvailable, updateInfo } = useUpdateStore(); + + const CURRENT_VERSION = import.meta.env.VITE_APP_VERSION || '0.1.0'; + + return ( +
+
+
+

应用更新

+

+ 当前版本:{CURRENT_VERSION} +

+
+ +
+ + {updateAvailable && updateInfo && ( +
+

发现新版本 {updateInfo.version}

+

+ {updateInfo.notes} +

+
+ )} +
+ ); +} +``` + +--- + +## 六、七牛云集成 + +### 6.1 七牛云配置 + +在 `python-api/.env` 中添加: + +```bash +# 七牛云配置 +QINIU_ACCESS_KEY=your-access-key +QINIU_SECRET_KEY=your-secret-key +QINIU_BUCKET_NAME=meijiaka-releases +QINIU_BUCKET_DOMAIN=cdn.meijiaka.com +``` + +### 6.2 七牛云服务封装 + +文件位置:`python-api/app/services/qiniu_service.py` + +```python +import os +import hashlib +from typing import Optional +from qiniu import Auth, BucketManager + +class QiniuService: + """七牛云服务封装""" + + def __init__(self): + access_key = os.getenv('QINIU_ACCESS_KEY') + secret_key = os.getenv('QINIU_SECRET_KEY') + self.bucket_name = os.getenv('QINIU_BUCKET_NAME') + self.domain = os.getenv('QINIU_BUCKET_DOMAIN') + + self.auth = Auth(access_key, secret_key) + self.bucket = BucketManager(self.auth) + + def get_file_url(self, key: str) -> str: + """获取文件访问 URL""" + return f"https://{self.domain}/{key}" + + def upload_file(self, local_path: str, key: str) -> dict: + """上传文件到七牛云""" + from qiniu import put_file_v2, etag + + token = self.auth.upload_token(self.bucket_name, key, 3600) + ret, info = put_file_v2(token, key, local_path, version='v2') + + if ret is None: + raise Exception(f"上传失败: {info}") + + return { + "key": ret['key'], + "hash": ret['hash'], + "url": self.get_file_url(key) + } + + def get_file_info(self, key: str) -> dict: + """获取文件信息""" + ret, info = self.bucket.stat(self.bucket_name, key) + if ret is None: + raise Exception(f"获取文件信息失败: {info}") + return ret + + def get_file_hash(self, local_path: str) -> str: + """计算本地文件 SHA256 哈希""" + sha256_hash = hashlib.sha256() + with open(local_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return f"sha256:{sha256_hash.hexdigest()}" + +# 全局单例 +_qiniu_service: Optional[QiniuService] = None + +def get_qiniu_service() -> QiniuService: + global _qiniu_service + if _qiniu_service is None: + _qiniu_service = QiniuService() + return _qiniu_service +``` + +--- + +## 七、部署流程 + +### 7.1 构建流程 + +```bash +# 1. 构建 Tauri 应用 +cd tauri-app +npm run tauri build + +# 2. 构建产物位置 +# macOS: src-tauri/target/release/bundle/dmg/ +# Windows: src-tauri/target/release/bundle/nsis/ +# Linux: src-tauri/target/release/bundle/appimage/ +``` + +### 7.2 上传到七牛云 + +文件位置:`python-api/scripts/upload_release.py` + +```python +#!/usr/bin/env python3 +""" +上传版本发布包到七牛云并创建版本发布记录 +""" + +import os +import sys +import argparse +import subprocess +from pathlib import Path + +# 添加项目路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.services.qiniu_service import get_qiniu_service +import httpx + +def upload_package(local_path: str, version: str) -> dict: + """上传单个安装包""" + qiniu = get_qiniu_service() + + filename = Path(local_path).name + key = f"releases/{version}/{filename}" + + # 计算哈希 + file_hash = qiniu.get_file_hash(local_path) + + # 上传 + print(f"上传 {filename}...") + result = qiniu.upload_file(local_path, key) + + # 获取文件信息 + file_info = qiniu.get_file_info(key) + + return { + "platform": filename.split('_')[2], + "architecture": filename.split('_')[3].split('.')[0], + "file_url": result['url'], + "file_size": file_info['fsize'], + "file_hash": file_hash + } + +def create_release(version: str, notes: str, packages: list, mandatory: bool = False): + """创建版本发布""" + api_url = "http://localhost:8080/api/v1/update/releases" + + response = httpx.post(api_url, json={ + "version": version, + "notes": notes, + "mandatory": mandatory, + "packages": packages + }) + + if response.status_code == 200: + print(f"版本 {version} 发布成功!") + return response.json() + else: + print(f"创建版本发布失败: {response.text}") + sys.exit(1) + +def main(): + parser = argparse.ArgumentParser(description="上传版本发布包") + parser.add_argument("--version", required=True, help="版本号 (如: 0.1.1)") + parser.add_argument("--notes", required=True, help="更新说明") + parser.add_argument("--mandatory", action="store_true", help="强制更新") + parser.add_argument("--path", required=True, help="构建产物目录") + + args = parser.parse_args() + + build_dir = Path(args.path) + + # 查找所有安装包 + packages = [] + for file in build_dir.rglob("*"): + if file.suffix in ['.dmg', '.exe', '.AppImage']: + pkg_info = upload_package(str(file), args.version) + packages.append(pkg_info) + + # 创建版本发布 + create_release(args.version, args.notes, packages, args.mandatory) + +if __name__ == "__main__": + main() +``` + +使用方式: +```bash +cd python-api +python scripts/upload_release.py \ + --version 0.1.1 \ + --notes "新功能:视频字幕压制\n修复:导出问题" \ + --path ../tauri-app/src-tauri/target/release/bundle +``` + +### 7.3 GitHub Actions 自动化(多平台) + +文件位置:`.github/workflows/release.yml` + +```yaml +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + # macOS 构建 + build-macos: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + target: x86_64-apple-darwin + + - name: Install Tauri CLI + run: cargo install tauri-cli --version "^2.0" + + - name: Install Python dependencies + run: | + cd python-api + python -m venv venv + source venv/bin/activate + pip install -e ".[dev]" + pip install qiniu httpx + + - name: Install Node dependencies + run: | + cd tauri-app + npm install + + - name: Build Tauri app (macOS) + run: | + cd tauri-app + npm run tauri build --target x86_64-apple-darwin + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-bundle + path: tauri-app/src-tauri/target/x86_64-apple-darwin/release/bundle/ + retention-days: 1 + + # Windows 构建 + build-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + target: x86_64-pc-windows-msvc + + - name: Install Tauri CLI + run: cargo install tauri-cli --version "^2.0" + + - name: Install Python dependencies + run: | + cd python-api + python -m venv venv + venv\Scripts\pip install -e ".[dev]" + venv\Scripts\pip install qiniu httpx + + - name: Install Node dependencies + run: | + cd tauri-app + npm install + + - name: Build Tauri app (Windows) +) + run: | + cd tauri-app + npm run tauri build --target x86_64-pc-windows-msvc + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-bundle + path: tauri-app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/ + retention-days: 1 + + # Linux 构建 + build-linux: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + target: x86_64-unknown-linux-gnu + + - name: Install Tauri CLI + run: cargo install tauri-cli --version "^2.0" + + - name: Install Python dependencies + run: | + cd python-api + python -m venv venv + source venv/bin/activate + pip install -e ".[dev]" + pip install qiniu httpx + + - name: Install Node dependencies + run: | + cd tauri-app + npm install + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev + + - name: Build Tauri app (Linux) + run: | + cd tauri-app + npm run tauri build --target x86_64-unknown-linux-gnu + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-bundle + path: tauri-app/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/ + retention-days: 1 + + # 统一发布 + release: + needs: [build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: macos-bundle + path: bundle/macos + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-bundle + path: bundle/windows + + - name: Download Linux artifacts + uses: actions/download-artifact@v4 + with: + name: linux-bundle + path: bundle/linux + + - name: Install Python dependencies + run: | + cd python-api + python -m venv venv + source venv/bin/activate + pip install -e ".[dev]" + pip install qiniu httpx + + - name: Upload to Qiniu and create release + env: + QINIU_ACCESS_KEY: ${{ secrets.QINIU_ACCESS_KEY }} + QINIU_SECRET_KEY: ${{ secrets.QINIU_SECRET_KEY }} + QINIU_BUCKET_NAME: ${{ secrets.QINIU_BUCKET_NAME }} + QINIU_BUCKET_DOMAIN: ${{ secrets.QINIU_BUCKET_DOMAIN }} + run: | + VERSION=${GITHUB_REF#refs/tags/v} + cd python-api + source venv/bin/activate + + # 上传所有平台的包 + python scripts/upload_release.py \ + --version $VERSION \ + --notes "Release $VERSION" \ + --path ../bundle +``` + +--- + +## 八、配置总结 + +### 8.1 功能配置 + +| 配置项 | 选择 | +|--------|------| +| OSS/COS 服务 | 七牛云 | +| 更新触发方式 | 启动时自动检查,需用户确认再下载 | +| 灰度发布 | 不需要 | +| 强制更新 | 需要 | +| 版本兼容性检查 | 不需要 | +| 更新检查频率 | 仅启动时 + 手动按钮 | +| 下载日志统计 | 需要 | +| macOS 安装位置 | ~/Applications(无需管理员) | +| 更新 UI 风格 | shadcn/ui 对话框 | +| 构建部署自动化 | 需要 | + +### 8.2 环境变量 + +**后端 (.env)**: +```bash +# 七牛云配置 +QINIU_ACCESS_KEY=your-access-key +QINIU_SECRET_KEY=your-secret-key +QINIU_BUCKET_NAME=meijiaka-releases +QINIU_BUCKET_DOMAIN=cdn.meijiaka.com + +# FastAPI 配置 +DATABASE_URL=postgresql+asyncpg://... +API_BASE_URL=http://localhost:8080 +``` + +**前端**: +```bash +# 应用版本(在构建时注入) +VITE_APP_VERSION=0.1.0 +``` + +### 8.3 文件清单 + +``` +python-api/ +├── app/ +│ ├── models/ +│ │ └── update.py # 数据模型 +│ ├── schemas/ +│ │ └── update.py # Pydantic schemas +│ ├── api/ +│ │ └── v1/ +│ │ └── update.py # API 路由 +│ ├── services/ +│ │ └── qiniu_service.py # 七牛云服务 +│ └── main.py # 注册路由 +├── scripts/ +│ ├── init_update_tables.sql # 数据库初始化 +│ └── upload_release.py # 上传和发布脚本 +└── .env # 环境变量 + +tauri-app/ +├── src/ +│ ├── components/ +│ │ └── UpdateDialog.tsx # 更新对话框 +│ ├── store/ +│ │ └── updateStore.ts # 更新状态管理 +│ └── pages/ +│ └── Settings.tsx # 设置页面集成 +└── src-tauri/ + ├── src/ + │ ├── updater.rs # Rust 更新模块 + │ └── lib.rs # 命令注册 + └── Cargo.toml # 依赖配置 + +.github/ +└── workflows/ + └── release.yml # 自动化发布 +``` + +--- + +## 九、测试指南 + +### 9.1 本地测试 + +1. **初始化数据库**: +```bash +cd python-api +psql -h localhost -U postgres -d meijiaka -f scripts/init_update_tables.sql +``` + +2. **创建测试版本**: +```bash +curl -X POST http://localhost:8080/api/v1/update/releases \ + -H "Content-Type: application/json" \ + -d '{ + "version": "0.1.1", + "notes": "测试版本", + "mandatory": false, + "packages": [ + { + "platform": "darwin", + "architecture": "x86_64", + "file_url": "https://cdn.meijiaka.com/releases/test.dmg", + "file_size": 102400000, + "file_hash": "sha256:test123" + } + ] + }' +``` + +3. **启动 Tauri 应用并测试更新流程** + +### 9.2 测试检查清单 + +- [ ] 版本检查 API 返回正确信息 +- [ ] 下载进度正确显示 +- [ ] 文件哈希验证工作正常 +- [ ] macOS 安装到 ~/Applications +- [ ] 安装后自动重启应用 +- [ ] 强制更新无法跳过 +- [ ] 下载日志正确记录 +- [ ] 手动检查更新按钮工作 + +--- + +## 十、常见问题 + +### Q1: 如何处理更新失败? + +**A**: Rust 后端会验证文件哈希,如果验证失败会删除损坏的文件并提示用户重试。建议实现重试机制,最多重试 3 次。 + +### Q2: 如何回滚版本? + +**A**: +1. 发布上一个版本为最新 +2. 或标记当前版本为不可用(修改 mandatory 和 notes) + +```sql +-- 回滚到上一个版本 +UPDATE app_releases +SET release_date = NOW() +WHERE version = '0.1.0'; +``` + +### Q3: 如何处理网络中断? + +**A**: Rust 下载器使用流式下载,支持断点续传。建议实现下载状态持久化,应用重启后继续下载。 + +### Q4: 如何测试更新流程? + +**A**: +1. 修改前端当前版本为旧版本 +2. 创建测试版本发布 +3. 启动应用测试更新流程 +4. 使用七牛云测试文件(不是真实安装包) + +### Q5: macOS 安装需要权限吗? + +**A**: 使用 ~/Applications 目录,用户权限即可,不需要管理员权限。 + +--- + +## 十一、错误处理与重试机制 + +### 11.1 下载重试机制 + +在 Rust 下载函数中添加重试逻辑: + +```rust +use std::time::Duration; + +#[tauri::command] +pub async fn download_update_with_retry( + app: AppHandle, + url: String, + file_hash: String, + expected_size: u64, + max_retries: u32, +) -> Result { + let mut last_error = String::new(); + + for attempt in 0..max_retries { + match download_update_internal(&app, &url, &file_hash, expected_size).await { + Ok(path) => return Ok(path), + Err(e) => { + last_error = e.clone(); + eprintln!("下载失败(尝试 {}/{}):{}", attempt + 1, max_retries, e); + + if attempt < max_retries - 1 { + // 指数退避 + let delay = Duration::from_secs(2_u64.pow(attempt)); + tokio::time::sleep(delay).await; + } + } + } + } + + Err(format!("下载失败,已重试 {} 次:{}", max_retries, last_error)) +} + +async fn download_update_internal( + app: &AppHandle, + url: &str, + file_hash: &str, + expected_size: u64, +) -> Result { + // 原有的下载逻辑 + // ... +} +``` + +### 11.2 安装失败回滚 + +macOS 安装失败时自动回滚: + +```rust +#[cfg(target_os = "macos")] +async fn install_macos(app: AppHandle, package_path: PathBuf) -> Result<(), String> { + use std::process::Command; + use std::thread; + use std::time::Duration; + + let mount_dir = PathBuf::from("/tmp/meijiaka_mount"); + let backup_path = PathBuf::from("/tmp/meijiaka_backup"); + + // 1. 挂载 DMG + if mount_dir.exists() { + let _ = Command::new("hdiutil") + .args(["detach", mount_dir.to_str().unwrap(), "-force"]) + .status(); + } + + let output = Command::new("hdiutil") + .args([ + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mount_dir.to_str().unwrap(), + package_path.to_str().unwrap(), + ]) + .output() + .map_err(|e| format!("Failed to mount DMG: {}", e))?; + + if !output.status.success() { + return Err(format!("Failed to mount DMG: {}", + String::from_utf8_lossy(output.stderr.as_slice()))); + } + + // 2. 查找应用文件 + let app_path = find_app_in_dmg(&mount_dir)?; + let app_name = app_path.file_name() + .ok_or("Invalid app path")? + .to_string_lossy() + .to_string(); + + let dest_dir = dirs::home_dir() + .ok_or("Failed to get home directory")? + .join("Applications"); + + fs::create_dir_all(&dest_dir) + .map_err(|e| format!("Failed to create Applications dir: {}", e))?; + + let dest_path = dest_dir.join(&app_name); + + // 3. 备份旧版本 + let backup_exists = dest_path.exists(); + if backup_exists { + fs::create_dir_all(&backup_path)?; + let _ = Command::new("cp") + .args(["-R", dest_path.to_str().unwrap(), backup_path.to_str().unwrap()]) + .status(); + } + + // 4. 删除旧版本 + if dest_path.exists() { + let _ = Command::new("rm") + .args(["-rf", dest_path.to_str().unwrap()]) + .status(); + } + + // 5. 复制新版本 + let output = Command::new("cp") + .args(["-R", app_path.to_str().unwrap(), dest_dir.to_str().unwrap()]) + .output() + .map_err(|e| format!("Failed to copy app: {}", e))?; + + if !output.status.success() { + // 复制失败,恢复备份 + if backup_exists { + eprintln!("安装失败,正在恢复旧版本..."); + let _ = Command::new("cp") + .args(["-R", + backup_path.join(&app_name).to_str().unwrap(), + dest_dir.to_str().unwrap()]) + .status(); + let _ = Command::new("rm") + .args(["-rf", backup_path.to_str().unwrap()]) + .status(); + } + return Err("Failed to copy app".to_string()); + } + + // 6. 验证新版本 + if !dest_path.exists() { + // 验证失败,恢复备份 + if backup_exists { + eprintln!("验证失败,正在恢复旧版本..."); + let _ = Command::new("cp") + .args(["-R", + backup_path.join(&app_name).to_str().unwrap(), + dest_dir.to_str().unwrap()]) + .status(); + } + return Err("New app not found after installation".to_string()); + } + + // 7. 清理备份 + if backup_exists { + let _ = Command::new("rm") + .args(["-rf", backup_path.to_str().unwrap()]) + .status(); + } + + // 8. 卸载 DMG + let _ = Command::new("hdiutil") + .args(["detach", mount_dir.to_str().unwrap(), "-force"]) + .status(); + + // 9. 延迟启动新版本 + thread::spawn(move || { + thread::sleep(Duration::from_secs(2)); + let _ = Command::new("open") + .args(["-a", &app_name]) + .status(); + }); + + // 10. 退出当前应用 + app.exit(0); + + Ok(()) +} +``` + +### 11.3 下载状态持久化 + +实现下载状态保存,应用重启后继续下载: + +```rust +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Serialize, Deserialize)] +struct DownloadState { + url: String, + file_hash: String, + expected_size: u64, + downloaded: u64, + save_path: String, +} + +fn save_download_state(app: &AppHandle, state: &DownloadState) -> Result<(), String> { + let cache_dir = app.path().app_cache_dir()?; + let state_file = cache_dir.join("updates/download_state.json"); + + let json = serde_json::to_string_pretty(state) + .map_err(|e| format!("Failed to serialize state: {}", e))?; + + fs::write(&state_file, json) + .map_err(|e| format!("Failed to save state: {}", e)) +} + +fn load_download_state(app: &AppHandle) -> Result, String> { + let cache_dir = app.path().app_cache_dir()?; + let state_file = cache_dir.join("updates/download_state.json"); + + if !state_file.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&state_file) + .map_err(|e| format!("Failed to read state: {}", e))?; + + let state: DownloadState = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse state: {}", e))?; + + Ok(Some(state)) +} +``` + +--- + +## 十二、安全性最佳实践 + +### 12.1 强制 HTTPS + +在 FastAPI 中强制使用 HTTPS: + +```python +from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware + +# 在生产环境强制 HTTPS +if not os.getenv("DEBUG"): + app.add_middleware(HTTPSRedirectMiddleware) +``` + +### 12.2 API 速率限制 + +使用 slowapi 实现速率限制: + +```python +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +# 配置速率限制 +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200/minute"], + storage_uri="redis://localhost:6379/1" +) + +app.state.limiter = limiter + +# 在检查更新接口添加速率限制 +@router.post("/check", response_model=UpdateInfoResponse) +@limiter.limit("10/minute") # 每分钟最多 10 次检查 +async def check_update( + request: UpdateCheckRequest, + db: AsyncSession = Depends(get_db) +): + # ... +``` + +### 12.3 文件签名验证 + +添加 HMAC 签名验证: + +```python +import hmac +import hashlib +from fastapi import Header, HTTPException + +def verify_file_signature( + file_path: str, + expected_signature: str, + secret: str +) -> bool: + """验证文件签名""" + with open(file_path, 'rb') as f: + file_hash = hashlib.sha256(f.read()).hexdigest() + + signature = hmac.new( + secret.encode(), + file_hash.encode(), + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected_signature) +``` + +### 12.4 CORS 配置 + +严格配置 CORS: + +```python +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "tauri://localhost", # Tauri 开发环境 + "http://localhost:1420", # Vite 开发服务器 + "https://yourdomain.com", # 生产域名 + ], + allow_credentials=True, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) +``` + +### 12.5 环境变量验证 + +在启动时验证必需的环境变量: + +```python +import os + +def validate_env_vars(): + """验证环境变量""" + required_vars = [ + "DATABASE_URL", + "QINIU_ACCESS_KEY", + "QINIU_SECRET_KEY", + "QINIU_BUCKET_NAME", + "QINIU_BUCKET_DOMAIN", + ] + + missing = [var for var in required_vars if not os.getenv(var)] + if missing: + raise RuntimeError(f"缺少必需的环境变量: {', '.join(missing)}") + +# 在应用启动时调用 +validate_env_vars() +``` + +### 12.6 敏感信息不记录 + +确保日志中不包含敏感信息: + +```python +import logging + +# 配置日志过滤器 +class SensitiveDataFilter(logging.Filter): + """过滤敏感数据""" + + SENSITIVE_KEYWORDS = ["access_key", "secret_key", "password", "token"] + + def filter(self, record): + msg = record.getMessage().lower() + if any(keyword in msg for keyword in self.SENSITIVE_KEYWORDS): + return False + return True + +# 添加过滤器 +logging.getLogger().addFilter(SensitiveDataFilter()) +``` + +--- + +## 十三、参考资料 + +- [Tauri 更新插件文档](https://v2.tauri.app/plugin/updater/) +- [七牛云 Python SDK](https://developer.qiniu.com/kodo/1242/python) +- [语义化版本规范](https://semver.org/lang/zh-CN/) +- [FastAPI 官方文档](https://fastapi.tiangolo.com/zh/) +- [FastAPI 安全最佳实践](https://fastapi.tiangolo.com/tutorial/security/) diff --git a/docs/database-design.md b/docs/database-design.md new file mode 100644 index 0000000..2b27bf6 --- /dev/null +++ b/docs/database-design.md @@ -0,0 +1,357 @@ +# 美家卡智影 - 数据库设计规范 + +> 企业级统一数据库命名和设计规范,遵循 "轻量云 + 全本地业务数据" 架构设计。 + +--- + +## 一、整体设计原则 + +| 原则 | 说明 | +|------|------| +| **统一前缀** | 所有业务表统一使用 `mjk_` 前缀(美家卡全称缩写)| +| **无外键设计** | 不使用数据库外键约束,数据一致性由业务层保证,架构更简洁灵活 | +| **软删除优先** | 使用 `deleted_at` 时间戳做软删除,保留历史数据便于排查 | +| **全小写下划线** | 命名全小写,单词用下划线分隔 | + +--- + +## 二、表命名规范 + +### 命名格式 + +``` +mjk_{module}_{description}[_logs] +``` + +- `mjk_` - 统一项目前缀 +- `module` - 业务模块名称 +- `description` - 内容描述 +- `_logs` 后缀 - 日志/统计类表(按事件增长) + +### 示例 + +| 表名 | 说明 | +|------|------| +| `mjk_users` | 用户账户表 | +| `mjk_model_usage_logs` | AI 模型调用日志表 | +| `mjk_avatars` | 数字人名片表(已废弃,数据迁移到本地)| +| `mjk_interface_request_logs` | 接口请求记录表 | + +--- + +## 三、字段命名规范 + +| 场景 | 规则 | 示例 | +|------|------|------| +| **主键** | 统一命名 `id`,类型 `BIGSERIAL` / `BIGINT` | `id BIGSERIAL PRIMARY KEY` | +| **外键引用** | 格式 `{referenced_table}_{primary_key}`,不要加前缀 | 引用 `mjk_users.id` → `user_id` | +| **布尔类型** | 前缀 `is_` 或 `has_` | `is_deleted`, `has_attachment` | +| **时间戳** | 后缀 `_at`,类型 `TIMESTAMP WITH TIME ZONE` | `created_at`, `updated_at`, `started_at`, `finished_at` | +| **状态字段** | 字段名固定 `status`,类型 `VARCHAR(N)`,存储枚举字符串 | `status VARCHAR(20) NOT NULL` | +| **软删除** | 字段名 `deleted_at`,允许 `NULL`,`NULL` 表示未删除 | `deleted_at TIMESTAMP WITH TIME ZONE` | + +--- + +## 四、约束与索引命名规范 + +| 对象 | 命名格式 | 示例 | +|------|---------|------| +| **主键** | `{table_name}_pkey`(PostgreSQL 默认) | `mjk_interface_request_logs_pkey` | +| **唯一约束** | `uk_{table_name}_{column_list}` | `uk_mjk_interface_request_logs_request_id` | +| **普通索引** | `idx_{table_name}_{column_list}` | `idx_mjk_interface_request_logs_user_id` | + +--- + +## 五、所有业务表结构 + +--- + +### 1. `mjk_users` - 用户基本信息表 + +存储用户基本认证信息,云端只存账户,不存业务数据。 + +```sql +CREATE TABLE mjk_users ( + id BIGSERIAL PRIMARY KEY, + mobile VARCHAR(20) UNIQUE NOT NULL, + nickname VARCHAR(64), + avatar_url TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE UNIQUE INDEX idx_mjk_users_mobile ON mjk_users(mobile); +``` + +**字段说明**: +| 字段 | 说明 | +|------|------| +| `id` | 用户唯一ID | +| `mobile` | 手机号(登录账号,唯一)| +| `nickname` | 用户昵称 | +| `avatar_url` | 头像URL | +| `created_at` | 创建时间 | +| `updated_at` | 最后更新时间 | + +--- + +### 2. `mjk_user_credits` - 用户积分账户记录表 + +记录用户积分账户的所有变动(充值、消费),每个变动一条记录。 + +```sql +CREATE TABLE mjk_user_credits ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(50) NOT NULL, + change_type VARCHAR(20) NOT NULL, -- recharge / consume + change_credits INTEGER NOT NULL, -- 变动积分数(充值正,消费负) + balance_before INTEGER NOT NULL, -- 变动前余额 + balance_after INTEGER NOT NULL, -- 变动后余额 + interface_type VARCHAR(50), -- 消费接口类型(消费时才有) + request_id VARCHAR(64), -- 关联接口请求ID + remark VARCHAR(200), -- 备注(充值订单号等) + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE INDEX idx_mjk_user_credits_user_id ON mjk_user_credits(user_id); +CREATE INDEX idx_mjk_user_credits_created_at ON mjk_user_credits(created_at); +CREATE INDEX idx_mjk_user_credits_change_type ON mjk_user_credits(change_type); +``` + +**字段说明**: +| 字段 | 说明 | +|------|------| +| `id` | 记录ID | +| `user_id` | 关联用户ID | +| `change_type` | 变动类型:`recharge`(充值) / `consume`(消费) | +| `change_credits` | 变动积分,充值为正,消费为负 | +| `balance_before` | 变动前积分余额 | +| `balance_after` | 变动后积分余额 | +| `interface_type` | 消费接口类型(仅消费时有)| +| `request_id` | 关联接口请求ID(可用于追溯)| +| `remark` | 备注,充值时存订单号 | +| `created_at` | 变动时间 | + +**余额计算**:用户当前余额 = `sum(change_credits)`,可以随时计算,也可以在用户表存冗余字段加速查询。 + +--- + +### 3. `mjk_model_usage_logs` - AI 模型调用日志表 + +记录每一次 AI 模型调用,用于成本统计和监控。 + +```sql +CREATE TABLE mjk_model_usage_logs ( + id BIGSERIAL PRIMARY KEY, + model_id VARCHAR(100) NOT NULL, + platform_id VARCHAR(50) NOT NULL, + task_type VARCHAR(50) NOT NULL, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + cost_cny FLOAT NOT NULL DEFAULT 0.0, + response_time_ms INTEGER, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_message TEXT, + user_id VARCHAR(50), + project_id VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE INDEX idx_mjk_model_usage_logs_user_id ON mjk_model_usage_logs(user_id); +CREATE INDEX idx_mjk_model_usage_logs_created_at ON mjk_model_usage_logs(created_at); +``` + +**字段说明**: +- `model_id` - AI 模型ID +- `platform_id` - AI 平台ID(openai/volcengine/klingai 等) +- `task_type` - 任务类型(script/polish/chat 等) +- `prompt_tokens` - 输入 Token 数 +- `completion_tokens` - 输出 Token 数 +- `total_tokens` - 总 Token 数 +- `cost_cny` - 消耗金额(人民币元) +- `response_time_ms` - 响应时间(毫秒) +- `success` - 是否成功 +- `error_message` - 错误信息 +- `user_id` - 关联用户ID +- `project_id` - 关联项目ID +- `created_at` - 创建时间 + +--- + +### 4. `mjk_interface_request_logs` - 接口请求记录表(新增) + +**按后端接口类型记录所有用户请求,统计积分消耗**。这张表是顶层的接口请求统计,每一次前端调用后端接口都记一条。 + +```sql +CREATE TABLE mjk_interface_request_logs ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64) NOT NULL, + user_id VARCHAR(50) NOT NULL, + interface_type VARCHAR(50) NOT NULL, + interface_name VARCHAR(100), + status VARCHAR(20) NOT NULL, + cost_credits INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMP WITH TIME ZONE NOT NULL, + finished_at TIMESTAMP WITH TIME ZONE, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 唯一约束 +ALTER TABLE mjk_interface_request_logs + ADD CONSTRAINT uk_mjk_interface_request_logs_request_id + UNIQUE (request_id); + +-- 索引 +CREATE INDEX idx_mjk_interface_request_logs_user_id + ON mjk_interface_request_logs(user_id); +CREATE INDEX idx_mjk_interface_request_logs_interface_type + ON mjk_interface_request_logs(interface_type); +CREATE INDEX idx_mjk_interface_request_logs_status + ON mjk_interface_request_logs(status); +CREATE INDEX idx_mjk_interface_request_logs_created_at + ON mjk_interface_request_logs(created_at); +``` + +**字段说明**: +| 字段 | 说明 | +|------|------| +| `id` | 日志记录自增ID | +| `request_id` | 本次请求唯一ID(全局唯一)| +| `user_id` | 请求用户ID | +| `interface_type` | 接口类型(枚举见下方)| +| `interface_name` | 接口名称(可读描述)| +| `status` | 请求状态:`success` / `failed` | +| `cost_credits` | 消耗积分数 | +| `started_at` | 请求开始时间 | +| `finished_at` | 请求结束时间 | +| `error_message` | 失败原因 | +| `created_at` | 记录创建时间 | + +**`interface_type` 枚举值**: + +| 值 | 说明 | +|----|------| +| `script_generate` | 脚本生成 | +| `script_polish` | 脚本润色 | +| `avatar_clone` | 数字人克隆 | +| `video_generate` | 数字人视频生成 | +| `subtitle_generate` | 字幕打轴生成 | +| `image_generate` | 封面图片生成 | + +--- + +### 5. `mjk_avatars` - 数字人名片表(已废弃) + +> **迁移计划**:原 `avatars` 表已废弃,所有数字人元数据全量迁移到用户本地存储。 +> 路径:`~/Documents/Meijiaka/avatars/{avatar_id}/meta.json` + +保留本表仅用于存量数据兼容,后续可删除。 + +```sql +CREATE TABLE mjk_avatars ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(50) NOT NULL, + name VARCHAR(64) NOT NULL, + voice_id VARCHAR(64), + element_id BIGINT, + voice_task_id VARCHAR(128), + element_task_id VARCHAR(128), + video_url TEXT NOT NULL, + trial_url TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + fail_reason TEXT, + deleted_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- 索引 +CREATE INDEX idx_mjk_avatars_user_id ON mjk_avatars(user_id); +CREATE INDEX idx_mjk_avatars_voice_task_id ON mjk_avatars(voice_task_id); +CREATE INDEX idx_mjk_avatars_element_task_id ON mjk_avatars(element_task_id); +``` + +--- + +## 六、本地存储结构(业务数据) + +所有业务数据(项目、脚本、数字人)都存在用户本地磁盘,云端只存储日志和统计: + +``` +~/Documents/Meijiaka/ +├── config.json # 全局应用配置 +├── projects/ +│ └── {project_id}/ +│ ├── meta.json # 项目元数据 +│ ├── segments.json # 脚本/分镜数据 +│ └── assets/ # 媒体文件 +├── avatars/ +│ └── {avatar_id}/ +│ ├── meta.json # 数字人元数据(id/name/voice_id/element_id/status 等) +│ └── source.mp4 # 原始上传视频 +└── cache/ # 临时文件 +``` + +**`avatars/{avatar_id}/meta.json` 结构**: + +```json +{ + "id": "avt_xxx", + "name": "我的数字人", + "voiceId": "kling-voice-id", + "elementId": 12345678, + "voiceTaskId": "kling-task-id", + "elementTaskId": "kling-task-id", + "videoUrl": "https://.../source.mp4", + "trialUrl": "https://.../trial.wav", + "status": "succeed", + "failReason": null, + "createdAt": "2026-04-16T10:00:00Z", + "updatedAt": "2026-04-16T10:05:00Z" +} +``` + +--- + +## 七、架构总结 + +| 数据类型 | 存储位置 | 说明 | +|---------|----------|------| +| 用户基本信息 | 云端 `mjk_users` | 必须存云端 | +| 用户积分变动记录 | 云端 `mjk_user_credits` | 记录充值/消费流水,统计用户余额 | +| AI 模型调用日志 | 云端 `mjk_model_usage_logs` | AI 模型细粒度调用日志(成本统计)| +| 接口请求记录 | 云端 `mjk_interface_request_logs` | 按后端接口记录请求、状态、消耗积分 | +| 项目/脚本/分镜 | 用户本地 JSON | 全本地业务数据 | +| 数字人元数据/原始视频 | 用户本地文件 | 全本地业务数据(原云端表已废弃)| +| 合成输出视频 | 用户本地文件 | 全本地 | + +完美符合设计理念:**轻量云账号 + 全本地业务数据**。 + +--- + +## 八、迁移说明 + +### 从无前缀版本迁移到统一前缀版本 + +1. 使用 Alembic 自动重命名所有现有表 + ```sql + ALTER TABLE users RENAME TO mjk_users; + ALTER TABLE model_usage_logs RENAME TO mjk_model_usage_logs; + ALTER TABLE avatars RENAME TO mjk_avatars; + ``` +2. 新建两张表: + - `mjk_user_credits` - 用户积分变动记录表 + - `mjk_interface_request_logs` - 接口请求记录表 +3. 修改所有 SQLAlchemy 模型中的 `__tablename__` +4. 后续:将 `mjk_avatars` 数据迁移到用户本地后可删除该表 + +--- + +*版本:v1.1* +*创建日期:2026-04-16* +*更新:新增 `mjk_user_credits` 积分账户表* diff --git a/docs/kling-api-dev.md b/docs/kling-api-dev.md new file mode 100644 index 0000000..dac6931 --- /dev/null +++ b/docs/kling-api-dev.md @@ -0,0 +1,4282 @@ +# 可灵AI (Kling) 新系统 API 开发规范及参照标准 + +> 本文档基于「可灵AI」新系统 API 接口文档整理 +> +> 调用域名:`https://api-beijing.klingai.com` +> +> 最后更新:2026年4月 + +--- + +「可灵AI」新系统 API 接口文档 + +注意: +新系统调用域名已由 https://api.klingai.com 变更为 https://api-beijing.klingai.com。(更新于2025年6月30日) +| 更新时间 | 更新说明 | +|---|---| +| 2026.04.01 | 【视频生成:3.0-Omni】细化参考视频对视频角色主体、多图主体和参考图数量的影响 | +详见image_list参数和element_list参数的参数说明 +本次更新仅细化说明,与原有逻辑一致 +| 2026.03.23 | 【通用】多图主体也可绑定音色 | +|---|---| +此前仅视频角色主体支持绑定音色 +多图主体绑定音色方式与视频角色主体一致,通过element_voice_id传参即可 +| 2026.03.18 | 【视频生成-视频特效】新增特效 | +|---|---| +新增6款特效:“关门,挂挡!”、“闭嘴!我的梦”、“法式优雅”、“手指滑滑变装”、“花神驾到”、“丝滑转场” +特效内容详见:特效模版中心 +| 2026.03.11 | 【视频生成:3.0-Omni】支持智能分镜 | +|---|---| +当shot_type参数值设为intelligence时,可实现智能分镜 +【视频生成】细化部分业务逻辑说明 +使用kling-video-o1模型生成首尾帧视频时,不支持引用主体 +通过视频定制主体,仅支持定制写实风格、人形主体 +创建主体 API 实例代码中 element_voice_id 参数格式为 string +多镜头不支持首尾帧 +非首尾帧图片可不传type参数 +音色控制功能支持模型范围说明 +| 2026.03.04 | 【视频生成-动作控制】V3.0 全新上线 | +|---|---| +可通过绑定主体提升主体一致性,绑定主体时只能参考视频中的人物朝向 +增加model_name参数区分模型版本,默认kling-v2-6 +生成标准模式动作控制视频,每秒扣减0.9积分;生成高品质模式动作控制视频,每秒扣减1.2积分 +| 2026.02.27 | 【视频生成-视频特效】新增特效 | +|---|---| +新增1款特效:被窝有“诈” +特效内容详见:特效模版中心 +| 2026.02.25 | 【视频生成】3.0 Omni、V3模型上线 | +|---|---| +【图像生成】3.0 Omni、V3模型上线 +【通用】主体相关功能全新升级 +支持通过视频创建主体,同时创建主体时可绑定音色并用于生成视频时指定音色。 +创建主体升级为异步服务,满足更多主体相关功能。 +新主体服务采用全新API(advanced-custom-elements),原有API可正常使用,但主体库相对独立,无法跨API查询。 +| 2026.02.11 | 【视频生成-视频特效】新增特效 | +|---|---| +新增9款特效:“八方来财舞”、“弥渡山歌”、“上花轿”、“好运舞 2”、“来财舞”、“雪夜之吻”、“永恒之吻”、“马力全开舞”和“秧歌舞” +特效内容详见:特效模版中心 +| 2026.02.04 | 【视频生成-视频特效】新增特效 | +|---|---| +新增8款特效:“你的专属烟花”、“掌心小人”、“手搓颜料变装”、“蹴鞠闹元宵”、“汤圆:我摊牌了”等 +特效内容详见:特效模版中心 +| 2026.02.03 | 【视频生成-视频特效】新增特效 | +|---|---| +新增9款特效:“嘻哈炫舞 2”、“鸽子舞”、“甜心舞 1”、“回村前后”等 +特效内容详见:特效模版中心 +| 2026.01.27 | 【视频生成-视频特效】新增特效 | +|---|---| +新增7款特效:“看看我家长龙宴”、“宠物出游、“冰雪奇迹”、“横移分身转场”、“马年烟花”、“辞旧迎新”等 +特效内容详见:特效模版中心 +| 2026.01.23 | 【通用】功能上新:支持智能补全主体不同角度图片 | +|---|---| +可通过主体正面图,自动推理出该主体其他角度图片,每次可生成3组结果供选择 +按服务访问次数计费,每次扣减0.5积分 +【通用】功能上新:查询任务结果时,可直接获取当前任务所消耗积分结果 +对应参数key为:final_unit_deduction +【通用】功能上新:生成图片或视频时,可同时生成含水印结果 +入参时将watermark_info中的enabled的参数设为true时,即可同时生成含水印结果 +返回结果的watermark_url参数为含水印版本 +目前仅部分API支持水印,详见当前API文档 +| 2026.01.16 | 【视频生成-视频特效】新增特效 | +|---|---| +新增31款特效:“醉酒舞”、“刀马舞、“舞狮贺岁”、“咚咚咚,红包来了”、“怪盗珠宝”、“镜头拉远”等 +特效内容详见:特效模版中心 +| 2025.12.22 | 【视频生成】动作控制功能全新上线 | +|---|---| +请注意,为保障生成效果,当前动作控制功能对输入的参考图和参考视频有较为严格的检测机制,检测失败无法生成(API不扣费),请参考【使用指南】中对图像和视频的要求进行使用 + +* + +基于新API路径实现,需同时上传参考图像和参考视频 +支持标准模式和高品质模式,生成标准模式视频时每秒扣减0.5积分,生成高品质模式视频时每秒扣减0.8积分,秒数四舍五入取整 +参考视频时长上限与所生成视频人物朝向相关:与参考视频一致时可达30秒,与参考图像一致时仅支持10秒 +暂不支持动作库 +| 2025.12.18 | 【视频生成-视频特效】新增特效 | +|---|---| +新增16款特效: “和他/她跨年”、“我的跨年分会场”、“下一秒圣诞”、“生日主角”等 +通知:“亲吻”、“打架”、“拥抱”、“给你点赞”、“老虎拥抱”、“养只狮子”、“3D卡通1” 特效将于2026.1.30 日下线 +特效内容详见:特效模版中心 +| 2025.12.16 | 【视频生成】V2.6模型能力升级,支持指定音色 | +|---|---| +通过prompt和voice_list实现,指定音色生视频:5s扣减6积分,10s扣减12积分 +支持音色定制,也可使用系统预置音色 +| 2025.12.15 | 【视频生成】V2.6模型上线 | +|---|---| +支持“文生视频”和“图生视频” +通过sound参数控制生成视频时是否包含同时生成配音 +【视频生成】Omni-Video模型上线 +全新API,仅通过提示词即可实现多种能力 +【图像生成】Omni-Image模型上线 +全新API,仅通过提示词即可实现多种能力 +| 2025.12.11 | 【视频生成-视频特效】新增特效 | +|---|---| +新增20款圣诞、新年、冬日主题特效: “2026 绽放时刻”、“圣诞惊喜礼盒”、“雪地童话”等 +通知:“一起来庆生”和“C4D卡通”特效将于2025.12.30 日下线 +特效内容详见:特效模版中心 +| 2025.12.04 | 【数字人】能力升级,支持生成5分钟数字人视频 | +|---|---| +无感升级,无需修改接口参数 +| 2025.11.25 | 【视频生成-视频特效】新增特效 | +|---|---| +新增10款单图特效: “感恩节气球游行”、“跳跳姜饼人”、“子弹时间”等 +特效内容详见:特效模版中心 +| 2025.11.19 | 【视频生成-视频特效】新增特效 | +|---|---| +新增7款单图特效: “光之精灵”、“测测你的守护神”、“单板滑雪”等 +特效内容详见:特效模版中心 +【多图参考生图】支持V2.1模型 +生成1张图片扣减16积分 +| 2025.11.17 | 【图生视频】V2.5-Turbo PRO支持首尾帧 | +|---|---| +同时传入image参数值和image_tail参数值即可实现 +生成5s视频扣减2.5积分,生成10s视频扣减5积分 +| 2025.11.11 | 【视频生成】V2.5-Turbo支持STD模式 | +|---|---| +“文生视频”和“图生视频”均已支持 +生成5s视频扣减1.5积分,生成10s视频扣减3积分 +| 2025.11.3 | 【数字人】视频生成效率提升,原小时级耗时压缩至10分钟+ | +|---|---| +无需修改任何代码 +| 2025.10.27 | 【视频生成-视频特效】新增特效 | +|---|---| +新增10款万圣节特效: “南瓜人变身”、“可爱幽灵变身”、“门外是谁-万圣节”、“万圣大逃亡”等 +特效内容详见:特效模版中心 +| 2025.10.20 | 【视频生成】文生视频和图生视频支持V2.5-turbo模型 | +|---|---| +支持高品质版,生成5s视频低至2.5积分 +【通用】能力上新:推出图片元素识别API,可用于多图参考生视频、多模态视频编辑功能 +可识别主体、面部、服装等,一次请求可获得4组结果(如有) +| 2025.10.15 | 【视频生成-视频特效】新增特效 | +|---|---| +新增8款单图特效: “内心真实想法”、“蹦床”、“下一秒发生什么”等 +特效内容详见:特效模版中心 +【数字人】全新功能上线 +基于图片与音频或文本生成动作表情自然、韵律与人声一致的视频 +如果输入图片中有多个人脸,暂不支持对人脸做指定 +| 2025.9.28 | 特别提醒:为保障信息安全,所有接口生成的图片/视频会在30天后被清理,为了避免影响使用,请您在生成后及时转存 | +|---|---| +| 2025.9.28 | 【视频生成-视频特效】新增 1 款特效 | +新增 1款单图特效: “万物皆可吃月饼” +特效内容详见:特效模版中心 +| 2025.9.26 | 【视频生成-视频特效】新增 3 款特效 | +|---|---| +新增 3款单图特效: “狂暴金刚”、“一飞冲天”、“洗刷刷洗刷刷” +特效内容详见:特效模版中心 +【语音合成】功能优化:支持合成1000长度内容的音频 +| 2025.9.15 | 【对口型】能力更新:支持多人画面对口型、开始对口型时间 | +|---|---| +通过face_id指定说话人,通过sound_insert_time指定开始对口型时间 +支持裁剪音频 +【语音合成】全新上线:上线文本转播报音,可实现试听功能 +可同时生成audio_id,可用于可灵任意API +| 2025.9.12 | 【视频生成-视频特效】新增 9 款特效 | +|---|---| +新增 9 款单图特效: “呼叫转移”、“捏一捏”等; +特效内容详见:特效模版中心 +| 2025.9.11 | 【对口型】能力更新:支持多人画面对口型、开始对口型时间 | +|---|---| +通过face_id指定说话人,通过sound_insert_time指定开始对口型时间 +支持裁剪音频 +【语音合成】全新上线:上线文本转播报音,可实现试听功能 +可同时生成audio_id,可用于可灵任意API +| 2025.9.5 | 【视频生成】能力更新:V2.1模型支持首尾帧 | +|---|---| +可生成5s或10s的视频,暂时仅支持高品质模式 +【视频生成】功能优化:视频生音效 +支持用户自行输入音效提示词、配乐提示词,以及开启ASMR模式 +| 2025.9.1 | 【视频生成-视频特效】新增 5 款特效 | +|---|---| +新增 5 款单图特效: “萌宠京剧”、“肌肉觉醒”等; +特效内容详见:特效模版中心 +| 2025.8.19 | 【视频生成-视频特效】新增 63 款特效 | +|---|---| +新增 62 款单图特效,1 款双人互动特效,累计 80 款特效可支持调用; +新增「特效模版中心」页面,支持查看特效详细信息与调用价格:特效模版中心 +| 2025.8.15 | 【视频生成】功能优化:视频生音效 | +|---|---| +视频音效生成支持​​全分辨率​​视频上传 +【多图参考生视频】功能优化:效果比上一版本提升 102% +主体一致性、动态质量、互动自然度等维度明显提升。 +无感升级,不需修改代码。 +【图像生成】模型更新:上线新V2.0模型,支持近300种风格 +参数示例:"model_name": "kling-v2-new" +| 2025.8.12 | 【视频生成】能力更新:文生视频支持V1.6 PRO | +|---|---| +参数示例:"mode": "pro" +可生成5s和10s的视频 +| 2025.8.1 | 【视频生成】新增能力:文生音效 | +|---|---| +支持通过输入文本描述(prompt)生成音效 +【视频生成】新增能力:视频生音效 +支持对所有可灵模型生成的视频,进行视频配音 +支持对用户自行上传的视频,进行视频配音 +| 2025.7.30 | 【图像生成】支持V2.1模型 | +|---|---| +文生图支持kling-v2-1模型 +【多图参考生图】全新上线 +支持主体参考subject_image_list、背景参考scene_image、风格参考style_image +单价0.4元,每生成1张图片从资源包总数里扣减16 +仅支持kling-v2模型 +| 2025.7.21 | 【视频生成-视频特效】新增单图特效 | +|---|---| +新增「单图特效」:7款,“果冻液压机jelly_press”、“果冻切一切jelly_slice”、“果冻捏一捏jelly_squish”、“果冻摇一摇jelly_jiggle”、“像素世界pixelpixel”、“美式证件照yearbook”、“一键拍立得instant_film” +包括创建任务、查询任务(单个)、查询任务(列表)接口 +【多模态视频编辑】全新上线 +支持对已有视频增加元素(addition)、替换元素(swap)、删除元素(removal) +使用时,需先初始化视频并对视频进行标记,再执行创建任务等操作 +| | | +|---|---| +| 2025.7.7 | 【视频生成-视频特效】新增单图特效 | +新增「单图特效」:2款,“一键变手办anime_figure”、“一飞冲天rocketrocket” +包括创建任务、查询任务(单个)、查询任务(列表)接口 +| 2025.6.30 | 【对口型】功能升级 | +|---|---| +支持视频时长上限从10秒增加至60秒 +生成视频耗时低至2分钟 +无需改造代码 +| 2025.6.19 | 【视频生成】支持V2.1模型 | +|---|---| +上线图生视频 V2.1 标准版,支持标准模式(STD)和高品质版(PRO) +上线图生视频 V2.1 大师版(Master) +上线文生视频 V2.1 大师版(Master) +| 2025.6.6 | 【图像生成】支持V2.0图生图模型,文生图模型支持选择分辨率(1K, 2K) | +|---|---| +【图像生成】支持扩图 +| 2025.5.13 | 【图像生成】支持V2.0模型 | +|---|---| +支持V2.0文生图模型 +【视频生成】支持V2.0模型 +支持V2.0文生视频模型、图生视频模型 +V2.0暂不支持mode参数 +【多图参考生视频】全新上线 +最多支持从4张图片中选取主体 +支持自定义生成视频的长宽比:16:9,9:16,1:1 +| 2025.4.25 | 【视频生成-视频特效】新增单图特效 | +|---|---| +新增「单图特效」:2款,“花花世界bloombloom”、“魔力转圈圈dizzydizzy” +包括创建任务、查询任务(单个)、查询任务(列表)接口 +| 2025.3.31 | 【视频生成】V1.6模型支持仅尾帧生成视频 | +|---|---| +可通过V1.6 高品质模型基于图片生成图片前几秒的视频画面 +【视频生成】V1.5模型、V1.6模型支持视频延长 +可基于V1.5模型和V1.6模型生成的视频,续写之后4~5秒的内容 +如果是用“仅尾帧”生成的视频,则续写之前4~5秒的内容 +| 2025.3.25 | 【图像生成】V1.5模型支持角色特征参考和人物长相参考 | +|---|---| +角色特征参考:通过文本描述即可随意改变人物的服装、发型、配饰、场景等元素,且可保持人物长相与参考图高度相似,轻易实现单人物多场景的创作需求 +人物长相参考:适用于人物和常见动物角色,可控信息由长相扩大到主体,同时支持用户分别调节长相和主体的相似强度,通过文本描述,可以将角色置于任何场景,为用户在创作阶段提供单角色多镜头多场景的稳定素材支持 +| 2025.3.12 | 【视频生成-视频特效】新增单图特效 | +|---|---| +开放「单图特效」:3款,“快来惹毛我fuzzyfuzzy”、“捏捏乐squish”与“万物膨胀expansion” +包括创建任务、查询任务(单个)、查询任务(列表)接口 +【视频生成】新模型支持首尾帧、仅尾帧、动态笔刷、运镜控制 +V1.5支持首尾帧、仅尾帧、动态笔刷、运镜控制 +V1.6支持首尾帧 +【视频生成】对口型支持自定义视频,支持更多可用音色 +支持为任意1080p或720p、10s内视频对口型 +新增8个中、英文音色可直接用于给对口型视频配音 +【图像生成】支持V1.5模型 +画面美感提升:构图与光影更加协调,尤其是人像美观度大幅提升,呈现更高级的美学效果 +画面质量提升:增强了画面细节表现,色彩还原更加自然,层次感更加丰富 +长宽比支持支持21:9 +| 2025.3.5 | 【视频生成】新增能力:视频创意特效 | +|---|---| +开放「双人互动特效」:3款,“拥抱hug”、亲吻kiss”、比心heart_gesture” +包括创建任务、查询任务(单个)、查询任务(列表)接口 +相比通用的视频生成接口,视频特效接口开放了更灵活的调用参数、封装了特效场景所需的前后处理能力(例如双人特效,支持传入两张人像图、并完成两张人像图的自动拼接,用拼接后的整图进行视频生成),调用更方便快捷 +| 2025.2.14 | 【图像生成】model字段变更 | +|---|---| +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 2025.1.7 | 【视频生成】V1.6模型正式上线 | +|---|---| +支持文生视频标准模式(STD),图生视频标准模式(STD)和高品质模式(PRO) +暂不支持尾帧和运动笔刷、运镜等控制类功能 +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 2024.12.30 | 【虚拟试穿】新增V1.5模型 | +|---|---| +V1.5模型是V1.0模型的全面升级版本 +V1.5模型支持单个服装(上装upper、下装lower、与连体装dress)试穿,以及“上装+下装”形式服装的组合试穿 +| 2024.12.23 | 【视频生成】新增能力:对口型 | +|---|---| +可灵 1.0 模型、可灵 1.5 模型生成的视频,只要满足视频画面的人脸条件,均支持对口型 +包括创建任务、查询任务(单个)、查询任务(列表)接口 +| 2024.12.9 | 【视频生成】V1.5模型,正式开放标准模式(STD)调用,支持视频生成 - 图生视频,暂不支持文生视频 | +|---|---| +支持标准模式 +不支持尾帧控制 +其他参数均支持 +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 2024.12.2 | 【视频生成】能力地图 | +|---|---| +由于视频生成模型有多个模型版本(V1,V1.5),且有多种插件能力(镜头控制/首尾帧/运动笔刷/续写...),为了方便大家更直观的查询不同版本、不同能力的开放情况,我们制作了“能力地图”方便大家查阅(详见“3-0能力地图”) +| 2024.11.29 | 【视频生成 - 图生视频】新增运动笔刷 | +|---|---| +仅支持V1.0模型的标准模式 5s 与高品质模式 5s,V1.5模型暂不支持 +| 2024.11.15 | 【视频生成】V1.5模型,正式开放高品质模式(PRO)调用,支持视频生成 - 图生视频,暂不支持文生视频 | +|---|---| +仅支持高品质模式 +不支持尾帧控制 +其他参数均支持 +【视频生成】新增能力:视频延长 +支持对V1.0模型生成的视频直接进行延长,每次增加4-5s的视频时长 +包括创建任务、查询任务(单个)、查询任务(列表)接口 +【视频生成】其他 +新增“external_task_id”字段,您可以在创建任务时自定义任务id,查询时也可以通过该自定义id查询视频 +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 2024.10.30 | 新增“查询资源包列表及余量”接口,方便您自主查询,见“六、账号信息查询” | +|---|---| +| 2024.10.25 | 增加对于模型生成物(图片/视频)存储时长的说明 | +为保障信息安全,生成的图片/视频会在30天后被清理,辛苦大家及时转存 +| 2024.10.15 | 增加生成鉴权信息的Java示例代码 | +|---|---| +| 2024.9.19 | 视频生成相关API | +创建任务时,请求参数里的正向提示词(prompt)和负向提示词(negative_prompt),字符数限制更新为:不超过2500个字符 +| 2024.9.19 | 正式支持“AI虚拟试穿”相关API(kolors-virtual-try-on) | +|---|---| + + +一、通用信息 +调用域名 +https://api-beijing.klingai.com +⚠️注意:新系统调用域名已由 https://api.klingai.com 变更为 https://api-beijing.klingai.com。此域名适用于服务器在中国地区的用户。 + +接口鉴权 +Step-1:获取 AccessKey + SecretKey +Step-2:您每次请求API的时候,需要按照固定加密方法生成API Token +加密方法:遵循JWT(Json Web Token, RFC 7519)标准 +JWT由三个部分组成:Header、Payload、Signature +示例代码(Python): + +示例代码(Java): + +Step-3:用第二步生成的API Token组装成Authorization,填写到 Request Header 里 +组装方式:Authorization = "Bearer XXX", 其中XXX填写第二步生成的API Token(注意Bearer跟XXX之间有空格) + +错误码 +| HTTP状态码 | 业务码 | 业务码定义 | 业务码解释 | 建议解决方案 | +|---|---|---|---|---| +| 200 | 0 | 请求成功 | - | - | +| 401 | 1000 | 身份验证失败 | 身份验证失败 | 检查Authorization是否正确 | +| 401 | 1001 | 身份验证失败 | Authorization为空 | 在Request Header中填写正确的Authorization | +| 401 | 1002 | 身份验证失败 | Authorization值非法 | 在Request Header中填写正确的Authorization | +| 401 | 1003 | 身份验证失败 | Authorization未到有效时间 | 检查token的开始生效时间,等待生效或重新签发 | +| 401 | 1004 | 身份验证失败 | Authorization已失效 | 检查token的有效期,重新签发 | +| 429 | 1100 | 账户异常 | 账户异常 | 检查账户配置信息 | +| 429 | 1101 | 账户异常 | 账户欠费(后付费场景) | 进行账户充值,确保余额充足 | +| 429 | 1102 | 账户异常 | 资源包已用完/已过期(预付费场景) | 购买额外的资源包,或开通后付费服务(如有) | +| 403 | 1103 | 账户异常 | 请求的资源无权限,如接口/模型 | 检查账户权限 | +| 400 | 1200 | 请求参数非法 | 请求参数非法 | 检查请求参数是否正确 | +| 400 | 1201 | 请求参数非法 | 参数非法,如key写错或value非法 | 参考返回体中message字段的具体信息,修改请求参数 | +| 404 | 1202 | 请求参数非法 | 请求的method无效 | 查看接口文档,使用正确的request method | +| 404 | 1203 | 请求参数非法 | 请求的资源不存在,如模型 | 参考返回体中message字段的具体信息,修改请求参数 | +| 400 | 1300 | 触发策略 | 触发平台策略 | 检查是否触发平台策略 | +| 400 | 1301 | 触发策略 | 触发平台的内容安全策略 | 检查输入内容,修改后重新发起请求 | +| 429 | 1302 | 触发策略 | API请求过快,超过平台速率限制 | 降低请求频率、稍后重试,或联系客服增加限额 | +| 429 | 1303 | 触发策略 | 并发或QPS超出预付费资源包限制 | 降低请求频率、稍后重试,或联系客服增加限额 | +| 429 | 1304 | 触发策略 | 触发平台的IP白名单策略 | 联系客服 | +| 500 | 5000 | 内部错误 | 服务器内部错误 | 稍后重试,或联系客服 | +| 503 | 5001 | 内部错误 | 服务器暂时不可用,通常是在维护 | 稍后重试,或联系客服 | +| 504 | 5002 | 内部错误 | 服务器内部超时,通常是发生积压 | 稍后重试,或联系客服 | + + +二、图像生成 +2-0 能力地图 +| kling-image-o1 | | 自定义长宽比(1K/2K) | 智能长宽比 | +|---|---|---|---| +| 文生图 | 单图生成 | ✅ | - | +| | 其他 | - | - | +| 图生图 | 单图生成 | ✅ | ✅ | +| | 主体控制 | | | +(仅多图主体) +✅ +✅ +| | 其他 | - | - | | +|---|---|---|---|---| +| | kling-v3-omni | | 自定义长宽比(1K/2K/4K) | 智能长宽比 | +| 文生图 | 单图生成 | ✅ | ✅ | | +| | 其他 | - | - | | +| 图生图 | 单图生成 | ✅ | ✅ | | +| | 组图生成 | ✅ | ✅ | | +| | 主体控制 | | | | +(仅多图主体) +✅ +✅ +| | 其他 | - | - | | | | | | | | +|---|---|---|---|---|---|---|---|---|---|---| +| | kling-v1 | | 1:1 | 16:9 | 4:3 | 3:2 | 2:3 | 3:4 | 9:16 | 21:9 | +| 文生图 | - | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | | +| 图生图 | 通用垫图 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | | +| | 其他能力 | - | - | - | - | - | - | - | - | | +| | kling-v1-5 | | 1:1 | 16:9 | 4:3 | 3:2 | 2:3 | 3:4 | 9:16 | 21:9 | +| 文生图 | - | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| 图生图 | 角色特征 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| | 人物长相 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| | 其他能力 | - | - | - | - | - | - | - | - | | + +| kling-v2 | | 1:1 | 16:9 | 4:3 | 3:2 | 2:3 | 3:4 | 9:16 | 21:9 | +|---|---|---|---|---|---|---|---|---|---| +| 文生图 | - | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| | | | | | | | | | | +图生图 +多图参考生图 +✅ +✅ +✅ +✅ +✅ +✅ +✅ +✅ +| | 风格转绘 | ✅(生成图片分辨率与入参图相同,不支持单独设置分辨率) | | | | | | | | | +|---|---|---|---|---|---|---|---|---|---|---| +| | 其他能力 | - | - | - | - | - | - | - | - | | +| | kling-v2-new | | 1:1 | 16:9 | 4:3 | 3:2 | 2:3 | 3:4 | 9:16 | 21:9 | +| 文生图 | - | - | - | - | - | - | - | - | - | | +| 图生图 | 风格转绘 | ✅(生成图片分辨率与入参图相同,不支持单独设置分辨率) | | | | | | | | | +| | 其他能力 | - | - | - | - | - | - | - | - | | +| | kling-v2-1 | | 1:1 | 16:9 | 4:3 | 3:2 | 2:3 | 3:4 | 9:16 | 21:9 | +| 文生图 | - | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| 图生图 | 通用垫图 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | - | | +| | 角色特征 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| | 人物长相 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| | 多图参考生图 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | +| | 风格转绘 | ✅(生成图片分辨率与入参图相同,不支持单独设置分辨率) | | | | | | | | | + +| kling-v3 | | 自定义长宽比(1K/2K) | 智能长宽比 | +|---|---|---|---| +| 文生图 | 单图生成 | ✅ | - | +| | 其他 | - | - | +| 图生图 | 单图生成 | ✅ | - | +| | 主体控制 | | | +(仅多图主体) +✅ +- +| | 其他 | - | - | | | | | +|---|---|---|---|---|---|---|---| +| | 与模型版本无关的能力 | 是否支持 | 描述 | | | | | +| 扩图 | ✅ | 可基于已有图片扩展内容 | | | | | | +| 其他 | - | | | | | | | +| | 模型 | kling-v1 | | kling-v1-5 | | kling-2 | | +| 模式 | 文生图 | 图生图 | 文生图 | 图生图 | 文生图 | 图生图 | | +| 清晰度 | 1K | 1K | 1K | 1K | 1K/2K | 1K | | + +2-1【Omni-Image】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/omni-image | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-image-o1 | 模型名称 | +枚举值:kling-image-o1,kling-v3-omni +| prompt | string | 必须 | 无 | 文本提示词,可包含正向描述和负向描述 | +|---|---|---|---|---| +可将提示词模板化来满足不同的图像生成需求 +不能超过2500个字符 +Omni模型可通过Prompt与图片等内容实现多种能力 +通过<<<>>>的格式来指定某个图片,如:<<>> +能力范围详见使用手册:可灵Omni模型使用指南 +| image_list | array | 可选 | 空 | 参考图列表 | +|---|---|---|---|---| +用key:value承载,如下: + +```json +[ + { + "image_url": "https://example.com/image.jpg" + } +] +``` + +支持传入图片Base64编码或图片URL(确保可访问) +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比要在1:2.5 ~ 2.5:1之间 +参考主体数量与参考图片数量有关,参考主体数量和参考图片数量之和不得超过10 +image_url参数值不得为空 +| element_list | array | 可选 | 空 | 主体参考列表 | +|---|---|---|---|---| +基于主体库中主体的ID配置,用key:value承载,如下: + +```json +[ + { + "element_id": "your_element_id" + } +] +``` + +参考主体数量与参考图片数量有关,参考主体数量和参考图片数量之和不得超过10 +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| resolution | string | 可选 | 1k | 生成图片的清晰度 | +|---|---|---|---|---| +枚举值:1k, 2k, 4k +1k:1K标清 +2k:2K高清 +4k:4K高清 +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| result_type | string | 可选 | single | 生成结果单图/组图切换开关 | +|---|---|---|---|---| +枚举值:single,series +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| n | int | 可选 | 1 | 生成图片数量 | +|---|---|---|---|---| +取值范围:[1,9] +当result_type值为series时,当前参数无效 +| series_amount | int | 可选 | 4 | 生成组图的图片数量 | +|---|---|---|---|---| +取值范围:[2, 9] +当result_type值为single时,当前参数无效 +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| aspect_ratio | string | 可选 | auto | 生成图片的画面纵横比(宽:高) | +|---|---|---|---|---| +枚举值:16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9, auto +其中:auto为根据传入内容智能生成图片宽高比 +参考原图横纵比生成新图时,当前参数无效 +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,用key:value承载,如下:: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +调用示例 +引入主体生成图像 + + +2-2【Omni-Image】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/omni-image/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 图片生成的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +2-3【Omni-Image】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/omni-image | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +2-4【图像生成】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/generations | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 + +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v1 | 模型名称 | +枚举值:kling-v1, kling-v1-5, kling-v2, kling-v2-new, kling-v2-1, kling-v3 +| prompt | string | 必须 | 无 | 正向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| negative_prompt | string | 可选 | 空 | 负向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +注:图生图(即image字段不为空时)场景下,不支持负向提示词 +| image | string | 可选 | 空 | 参考图片 | +|---|---|---|---|---| +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 +image_reference参数不为空时,当前参数必填 +| image_reference | string | 可选 | 无 | 图片参考类型 | +|---|---|---|---|---| +枚举值:subject(角色特征参考), face(人物长相参考) +使用face(人物长相参考)时,上传图片需仅含1张人脸。 +使用kling-v1-5且image参数不为空时,当前参数必填 +| image_fidelity | float | 可选 | 0.5 | 生成过程中对用户上传图片的参考强度 | +|---|---|---|---|---| +取值范围:[0,1],数值越大参考强度越大 +仅 kling-v1, kling-v1-5 支持当前参数 +| human_fidelity | float | 可选 | 0.45 | 面部参考强度,即参考图中人物五官相似度 | +|---|---|---|---|---| +取值范围:[0,1],数值越大参考强度越大 +仅image_reference参数为subject时生效 +仅 kling-v1-5 支持当前参数 +| element_list | array | 可选 | 空 | 主体参考列表 | +|---|---|---|---|---| +基于主体库中主体的ID配置,用key:value承载,如下: + +```json +[ + { + "element_id": "your_element_id" + } +] +``` + +参考主体数量与参考图片数量有关,参考主体数量和参考图片数量之和不得超过10 +| resolution | string | 可选 | 1k | 生成图片的清晰度 | +|---|---|---|---|---| +枚举值:1k, 2k +1k:1K标清 +2k:2K高清 +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| n | int | 可选 | 1 | 生成图片数量 | +|---|---|---|---|---| +取值范围:[1,9] +| aspect_ratio | string | 可选 | 16:9 | 生成图片的画面纵横比(宽:高) | +|---|---|---|---|---| +枚举值:16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9 +不同模型版本支持范围不同,详见当前文档2-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +调用示例 +引入主体生成图像 + + +2-5【图像生成】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/generations/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 图片生成的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +2-6【图像生成】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/generations | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/images/generations?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +2-7【多图参考生图】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/multi-image2image | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v2 | 模型名称 | +枚举值:kling-v2, kling-v2-1 +| prompt | string | 可选 | 空 | 正向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| subject_image_list | array | 必须 | 无 | 参考主体图片列表 | +|---|---|---|---|---| +最多支持4张图片,最少支持1张图片,用key:value承载,如下: + +API端无裁剪逻辑,请直接上传已选主体后的片 +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比要在1:2.5 ~ 2.5:1之间 +| scene_image | string | 可选 | 空 | 场景参考图 | +|---|---|---|---|---| +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 +| style_image | string | 可选 | 空 | 风格参考图 | +|---|---|---|---|---| +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 +| n | int | 可选 | 1 | 生成图片数量 | +|---|---|---|---|---| +取值范围:[1,9] +| aspect_ratio | string | 可选 | 16:9 | 生成图片的画面纵横比(宽:高) | +|---|---|---|---|---| +枚举值:16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 空 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +2-8【多图参考生图】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/multi-image2image/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 图片生成的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +2-9【多图参考生图】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/multi-image2image | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/images/generations?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +2-10【扩图】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/editing/expand | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| image | string | 必须 | 空 | 参考图片 | +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片分辨率不小于300*300px,图片宽高比要在1:2.5 ~ 2.5:1之间 +| up_expansion_ratio | float | 必须 | 0 | 向上扩充范围;基于原图高度的倍数而计算 | +|---|---|---|---|---| +取值范围:[0,2],新图片整体面积不得超过原图片3倍 +如原图高20,当前参数值为0.1,则: +原图顶边距离新图顶边为20 x 0.1 = 2,区域内均为扩图范围 +| down_expansion_ratio | float | 必须 | 0 | 向下扩充范围;基于原图高度的倍数而计算 | +|---|---|---|---|---| +取值范围:[0,2],新图片整体面积不得超过原图片3倍 +如原图高20,当前参数值为0.2,则: +原图底边距离新图底边为20 x 0.2 = 4,区域内均为扩图范围 +| left_expansion_ratio | float | 必须 | 0 | 向左扩充范围;基于原图宽度的倍数而计算 | +|---|---|---|---|---| +取值范围:[0,2],新图片整体面积不得超过原图片3倍 +如原图宽30,当前参数值为0.3,则: +原图左边距离新图左边为30 x 0.3 = 9,区域内均为扩图范围 +| right_expansion_ratio | float | 必须 | 0 | 向右扩充范围;基于原图宽度的倍数而计算 | +|---|---|---|---|---| +取值范围:[0,2],新图片整体面积不得超过原图片3倍 +如原图宽30,当前参数值为0.4,则: +原图右边距离新图右边为30 x 0.4 = 12,区域内均为扩图范围 +| prompt | string | 可选 | 无 | 正向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| n | int | 可选 | 1 | 生成图片数量 | +|---|---|---|---|---| +取值范围:[1,9] +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +示例代码 + + +2-11【扩图】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/editing/expand/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 图片生成的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +2-12【扩图】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/editing/expand | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +2-13【通用】智能补全主体图 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/ai-multi-shot | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| element_frontal_image | string | 必须 | 无 | 主体正面参考图 | +支持传入图片Base64编码或图片URL(确保可访问) +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比要在1:2.5 ~ 2.5:1之间 +| callback_url | string | 可选 | 空 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +2-14【通用】查询智能补充主体图任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/ai-multi-shot/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 任务ID | +请求路径参数,直接将值填写在请求路径中 +请求体 +无 +响应体 + + +2-15【通用】查询智能补充主体图任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/ai-multi-shot | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +查询参数 +/v1/general/ai-multi-shot?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + + +三、视频生成 +3-0 能力地图 +| kling-video-o1 | | std(3s~10s) | pro(3s~10s) | +|---|---|---|---| +| 文生视频 | 单镜头视频生成 | ✅(仅5s、10s) | ✅(仅5s、10s) | +| | 声音控制(人声控制) | ❌ | ❌ | +| | 其他 | - | - | +| 图生视频 | 单镜头视频生成(仅首帧) | ✅(仅5s、10s) | ✅(仅5s、10s) | +| | 首尾帧(一镜到底) | ✅ | ✅ | +| | 主体控制 | | | +(仅多图主体) +✅ +✅ +| | 视频参考(含视频编辑) | ✅ | ✅ | | +|---|---|---|---|---| +| | 声音控制(人声控制) | ❌ | ❌ | | +| | 其他 | - | - | | +| | kling-v3-omni | | std(3s~15s) | pro(3s~15s) | +| 文生视频 | 单镜头视频生成 | ✅ | ✅ | | +| | 多镜头视频生成 | ✅ | ✅ | | +| | 声音控制(人声控制) | ❌ | ❌ | | +| | 其他 | - | - | | +| 图生视频 | 单镜头视频生成 | ✅ | ✅ | | +| | 多镜头视频生成 | ✅ | ✅ | | +| | 首尾帧(一镜到底) | ✅ | ✅ | | +| | 主体控制 | | | | +(视频角色主体+多图主体) +✅ +✅ +| | 视频参考 | ✅(仅3s~10s) | ✅(仅3s~10s) | +|---|---|---|---| +| | 声音控制(人声控制) | ❌ | ❌ | +| | 其他 | - | - | + +| kling-v1 | | std 5s | std 10s | pro 5s | pro10s | +|---|---|---|---|---|---| +| 文生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | +| | 运镜控制 | ✅ | - | - | - | +| 图生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | +| | 首尾帧 | ✅ | - | ✅ | - | +| | 运动笔刷 | ✅ | - | ✅ | - | +| | 其他能力 | - | - | - | - | +| 视频续写 | | | | | | +(不支持设置负向提示词和参考强度) + +✅ +✅ +✅ +✅ +| 视频特效-双人特效 | +|---| +拥抱,亲吻,比心 + +✅ +✅ +✅ +✅ +| 其他 | | - | - | - | - | | +|---|---|---|---|---|---|---| +| | kling-v1-5 | | std 5s | std 10s | pro 5s | pro10s | +| 文生视频 | 视频生成 | - | - | - | - | | +| | 其他能力 | - | - | - | - | | +| 图生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | | +| | 首尾帧 | - | - | ✅ | ✅ | | +| | 仅尾帧 | - | - | ✅ | ✅ | | +| | 运动笔刷 | - | - | ✅ | - | | +| | 运镜控制 | | | | | | +(仅simple) +- +- +✅ +- +| | 其他能力 | - | - | - | - | +|---|---|---|---|---|---| +| 视频续写 | | ✅ | ✅ | ✅ | ✅ | +| 视频特效-双人特效 | | | | | | +拥抱,亲吻,比心 + +✅ +✅ +✅ +✅ +| 其他 | | - | - | - | - | +|---|---|---|---|---|---| + +| kling-v1-6 | | std 5s | std 10s | pro 5s | pro10s | +|---|---|---|---|---|---| +| 文生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | +| | 其他能力 | - | - | - | - | +| 图生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | +| | 首尾帧 | - | - | ✅ | ✅ | +| | 仅尾帧 | - | - | ✅ | ✅ | +| | 其他能力 | - | - | - | - | +| 多图参考生视频 | | ✅ | ✅ | ✅ | ✅ | +| 多模态视频编辑 | | ✅ | ✅ | ✅ | ✅ | +| 视频续写 | | ✅ | ✅ | ✅ | ✅ | +| 视频特效-双人特效 | | | | | | +拥抱,亲吻,比心 + +✅ +✅ +✅ +✅ +| | kling-v2-master | | 5s | 10s | | | +|---|---|---|---|---|---|---| +| 文生视频 | 视频生成 | ✅ | ✅ | | | | +| | 其他能力 | - | - | | | | +| 图生视频 | 视频生成 | ✅ | ✅ | | | | +| | 其他能力 | - | - | | | | +| 其他 | | - | - | | | | +| | kling-v2-1 | | std 5s | std 10s | pro 5s | pro10s | +| 文生视频 | 全部能力 | - | - | - | - | | +| 图生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | | +| | 首尾帧 | - | - | ✅ | ✅ | | +| | 其他 | - | - | - | - | | +| 其他 | | - | - | - | - | | + +| kling-v2-1-master | | 5s | 10s | | | | | | +|---|---|---|---|---|---|---|---|---| +| 文生视频 | 视频生成 | ✅ | ✅ | | | | | | +| | 其他能力 | - | - | | | | | | +| 图生视频 | 视频生成 | ✅ | ✅ | | | | | | +| | 其他能力 | - | - | | | | | | +| 其他 | | - | - | | | | | | +| | kling-v2-5-turbo | | std 5s | std 10s | pro 5s | pro10s | | | +| 文生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | | | | +| | 其他 | - | - | - | - | | | | +| 图生视频 | 视频生成 | ✅ | ✅ | ✅ | ✅ | | | | +| | 首尾帧 | - | - | ✅ | ✅ | | | | +| | 其他 | - | - | - | - | | | | +| 其他 | | - | - | - | - | | | | +| | kling-v2-6 | | std 5s | std 10s | std 其他时长 | pro 5s | pro10s | pro 其他时长 | +| 文生视频 | 视频生成 | ✅(仅无声视频) | ✅(仅无声视频) | - | ✅ | ✅ | - | | +| | 其他 | - | - | - | - | - | - | | +| 图生视频 | 视频生成 | ✅(仅无声视频) | ✅(仅无声视频) | - | ✅ | ✅ | - | | +| | 首尾帧 | - | - | - | ✅(仅无声视频) | ✅(仅无声视频) | - | | +| | 声音控制(人声控制) | - | - | - | ✅ | ✅ | - | | +| | 动作控制 | - | - | ✅ | - | - | ✅ | | +| | 其他 | - | - | - | - | - | - | | + +| kling-v3 | | std(3~15s) | pro(3~15s) | +|---|---|---|---| +| 文生视频 | 单镜头视频生成 | ✅ | ✅ | +| | 多镜头视频生成 | ✅ | ✅ | +| | 声音控制(人声控制) | ❌ | ❌ | +| | 其他 | - | - | +| 图生视频 | 单镜头视频生成(仅首帧) | ✅ | ✅ | +| | 多镜头视频生成 | ✅ | ✅ | +| | 首尾帧(一镜到底) | ✅ | ✅ | +| | 主体控制 | | | +(视频角色主体+多图主体) +✅ +✅ +| | 动作控制 | ✅ | ✅ | | | | | | | | +|---|---|---|---|---|---|---|---|---|---|---| +| | 声音控制(人声控制) | ❌ | ❌ | | | | | | | | +| | 其他 | - | - | | | | | | | | +| | 与模型版本无关的能力 | 是否支持 | 描述 | | | | | | | | +| 数字人 | ✅ | 只需一张照片即可生成数字人播报类视频 | | | | | | | | | +| 对口型 | ✅ | 可结合文案或音频,驱动视频中角色的口型 | | | | | | | | | +| 视频生音效 | ✅ | 支持为所有可灵模型生成的视频和用户上传的符合视频格式要求的视频添加音效 | | | | | | | | | +| 文生音效 | - | 支持通过输入文本描述(prompt)生成音效 | | | | | | | | | +| 其他 | - | - | | | | | | | | | +| | 模型 | kling-v1 | | kling-v1-5 图生视频 | | kling-v1-6 图生视频 | | kling-v1-6 文生视频 | | kling-v2 Master | +| 模式 | STD | PRO | STD | PRO | STD | PRO | STD | PRO | - | | +| 分辨率 | 720p | 720p | 720p | 1080p | 720p | 1080p | 720p | 1080p | 720p | | +| 帧率 | 30fps | 30fps | 30fps | 30fps | 30fps | 30fps | 24fps | 24fps | 24fps | | + +| 模型版本 | kling-v2-1 图生视频 | | kling-v2-1 Master | kling-v2-5 图生视频 | kling-v2-5 文生视频 | +|---|---|---|---|---|---| +| 模式 | STD | PRO | - | PRO | PRO | +| 分辨率 | 720p | 1080p | 1080p | 1080p | 1080p | +| 帧率 | 24fps | 24fps | 24fps | 24fps | 24fps | + +3-1【Omni-Video】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/omni-video | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-video-o1 | 模型名称 | +枚举值:kling-video-o1, kling-v3-omni +| multi_shot | boolean | 可选 | false | 是否生成多镜头视频 | +|---|---|---|---|---| +当前参数为true时,prompt参数无效,且不支持设定首尾帧生视频 +当前参数为false时,shot_type参数及multi_prompt参数无效 +| shot_type | string | 可选 | 空 | 分镜方式 | +|---|---|---|---|---| +枚举值:customize, intelligence +当multi_shot参数为true时,当前参数必填 +| prompt | string | 可选 | 空 | 文本提示词,可包含正向描述和负向描述 | +|---|---|---|---|---| +可将提示词模板化来满足不同的视频生成需求 +Omni模型可通过Prompt与主体、图片、视频等内容实现多种能力 +通过<<<>>>的格式来指定某个主体、图片、视频,如:<<>>、<<>>、<<>> +更多信息详见:可灵视频 3.0 Omni 使用指南 +长度不能超过2500个字符 +当“multi_shot参数为false”或“multi_shot参数为true且shot-type参数为intelligence”时,当前参数不得为空 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| multi_prompt | array | 可选 | 空 | 各分镜信息,如提示词、时长等 | +|---|---|---|---|---| +通过index、prompt、duration参数定义分镜序号及相应提示词和时长,其中: +最多支持6个分镜,最小支持1个分镜 +每个分镜相关内容的最大长度不超过512 +每个分镜的时长不大于当前任务的总时长,不小于1 +所有分镜的时长之和等于当前任务的总时长 +用key:value承载,如下: + +当mult_shot参数为true且shot_type参数为customize时,当前参数不得为空 +| image_list | array | 可选 | 空 | 参考图列表 | +|---|---|---|---|---| +包括主体、场景、风格等参考图片,也可作为首帧或尾帧生成视频;当作为首帧或尾帧生成视频时: +通过type参数来定义图片是否为首尾帧:first_frame为首帧,end_frame为尾帧;其中: +如图片非首帧或尾帧,请勿配置type参数 +暂时不支持仅尾帧,即有尾帧图时必须有首帧图 +首帧或首尾帧生视频时,不能使用视频编辑功能 +用key:value承载,如下: + +```json +[ + { + "image_url": "https://example.com/image.jpg" + } +] +``` + +支持传入图片Base64编码或图片URL(确保可访问) +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比要在1:2.5 ~ 2.5:1之间 +参考图片数量与参考主体数量和参考主体类型有关,其中: +无参考视频+仅有多图主体时,参考图片与多图主体数量之和不得超过7; +无参考视频+有视频主体时,参考图片与多图主体数量之和不得超过4; +有参考视频+仅有多图主体时,参考图片与多图主体数量之和不得超过4; +使用kling-video-o1模型时,数组中超过2张图片时,不支持设置首尾帧 +image_url参数值不得为空 +| element_list | array | 可选 | 空 | 参考主体列表 | +|---|---|---|---|---| +基于主体库中主体的ID配置,用key:value承载,如下: + +主体分为视频定制主体(简称:视频角色主体)和图片定制主体(简称:多图主体),适用范围不同,请注意区分 +参考主体数量与主体类型、有无参考视频、参考图片数量等因素有关,其中: +当使用首帧或首尾帧生成视频时,kling-v3-omni模型最多支持3个主体; +当使用首尾帧生成视频时,kling-video-o1模型不支持主体; +无参考视频+仅有多图主体时,参考图片与多图主体数量之和不得超过7; +无参考视频+仅有视频角色主体时,视频角色主体数量不得超过3; +无参考视频+同时有视频角色主体和多图主体时,视频角色主体数量不得超过3,参考图片与多图主体数量之和不得超过4; +有参考视频+仅有多图主体时,参考图片与多图主体数量之和不得超过4; +有参考视频时,不支持使用视频角色主体; +更多主体信息详见:可灵「主体库 3.0」使用指南 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| video_list | array | 可选 | 空 | 参考视频,通过URL方式获取 | +|---|---|---|---|---| +可作为特征参考视频,也可作为待编辑视频,默认为待编辑视频;可选择性保留视频原声 +通过refer_type参数区分参考视频类型:feature为特征参考视频,base为待编辑视频 +参考视频为待编辑视频时,不能定义视频首尾帧 +通过keep_original_sound参数选择是否保留视频原声,yes为保留,no为不保留;当前参数对特征参考视频(feature)也生效 +有参考视频时,sound参数值只能为off +用key:value承载,如下: + +```json +[ + { + "video_url": "https://example.com/video.mp4", + "refer_type": "feature", + "keep_original_sound": "yes" + } +] +``` + +视频格式仅支持MP4/MOV +视频时长不少于3秒,上限与模型版本有关,详见能力地图 +视频宽高尺寸需介于720px(含)和2160px(含)之间 +视频帧率基于24fps~60fps,生成视频时会输出为24fps +至多仅支持上传1段视频,视频大小不超过200MB +video_url参数值不得为空 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| sound | string | 可选 | off | 生成视频时是否同时生成声音 | +|---|---|---|---|---| +枚举值:on,off +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| mode | string | 可选 | pro | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| aspect_ratio | string | 可选 | 空 | 生成视频的画面纵横比(宽:高) | +|---|---|---|---|---| +枚举值:16:9, 9:16, 1:1 +未使用首帧参考或视频编辑功能时,当前参数必填 +| duration | string | 可选 | 5 | 生成视频时长,单位s | +|---|---|---|---|---| +枚举值:3,4,5,6,7,8,9,10,11,12,13,14,15,其中: +使用视频编辑功能("refer_type":"base")时,输出结果与传入视频时长相同,此时当前参数无效;此时,按输入视频时长四舍五入取整计量计费 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 空 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +场景调用示例 +图片/主体参考 +参考图片/主体里的角色/道具/场景等多种元素,灵活生成视频 + +| 技能及 Prompt 撰写格式 | 输入视频/图片/主体 | C端Prompt | C端生成效果 | B端请求体 | B端生成效果 | +|---|---|---|---|---|---| +| 参考图片 | | | | | | +参考 【@图片1】 的【参考内容,如人物】和 【@图片2】的【参考内容,如背景】生成视频,保持图片特征一致。 +** +【@图片1】的女孩和【@图片2】的男孩挽手并肩在东京街头散步 +* + +* +* + +| 参考主体 | +|---| +参考 【@主体1】 的【形象特征】与 【@主体2】的【场景特征】 生成视频,保持主体一致性。 +* +【@爆炸头的小男孩】走进【@温馨房间】 +* + +* +* + +| 参考图片和主体 | +|---| +参考 【@图片】 的【参考内容,如人物】和 【@主体】的【参考内容,如背景】生成视频。 +* +** + +* +* +* +* + +指令变换 +视频编辑,例如视频增加内容/删除内容/修改内容(主体/背景/局部/视频风格/物体颜色/天气/...)/切换景别/切换视角 + +| 技能及 Prompt 撰写格式 | 输入视频/图片/主体 | C端Prompt | C端生成效果 | B端请求体 | B端生成效果 | +|---|---|---|---|---|---| +| 在【@视频】中增加 [描述增加内容] | * | | | | | + + + +在【@视频】中的主体身后远处增加【@图片1】中的怪物,怪物从远处慢慢朝着主体走来 + +* + +* +* + +| 视频删除内容 | +|---| +删除【@视频】中的 [描述要删除内容] +* + +删除【@视频】中道路两侧的路人,保留马车 +* + +* +* + + +| 修改视频主体 | +|---| +把【@视频】中 [描述指定主体] 修改为【@图片】中 [目标主体]。 +* + +把【@视频】中的雕像改为【@图片】中的姜饼人 +* + +* + + +| 修改视频局部内容 | +|---| +把【@视频】中的【描述主体局部】修改为【描述目标内容】 + +* + +【@视频】中的长剑从剑鞘抽出时,只有露出的剑身逐渐变成【@图片1】中的8-bit像素风格的数字化剑身效果。剑鞘保持原样不变。随着剑刃滑出,像素块闪烁出现,剑身呈现复古像素光纹与数字方块跳动。 +* + +* +* + +| 修改视频背景 | +|---| +把【@视频】中的背景修改为【描述目标背景】 +* + + + +Convert the ocean in [@视频1] into the city in [@图片1] + + +* + +* +* +| 修改视频视角 | +|---| +把【@视频】修改为【目标视角】 + +* + +【@视频】生成这段视频的侧面极致特写,景深,晃动镜头 +* + +* +* + +| 视频绿幕抠像 | +|---| +把【@视频】的背景改成绿幕,保留 [描述保留内容] +* + +把【@视频】的背景改为绿幕,保留画面中的人物和水母 +* + +* +* + +| 改风格 | +|---| +把【@视频】转变为【指定风格】 +* + + +把【@视频】转变为美式卡通风格 +* + +* +* +| | | | | | | +|---|---|---|---|---|---| +| | | | | | | +| | | | | | | +| | | | | | | +| | | | | | | +| | | | | | | +| | | | | | | + +视频参考 +参考视频内容生成下一个镜头/上一个镜头,或者参考视频的风格/运镜方式进行视频生成 + +* +| 技能及 Prompt 撰写格式 | 输入视频/图片/主体 | C端Prompt | C端生成效果 | B端请求体 | B端生成效果 | +|---|---|---|---|---|---| +| 生成下一个镜头 | | | | | | +基于【@视频】生成下一个镜头:[描述镜头内容] +* + +基于【@视频】,生成下一个镜头:镜头位于后座,以中景拍摄前排中老年男子和年轻男性。两人身体微背向,形成对立三角结构。并向各自的车窗玻璃扭头向外看去。背景虚化。氛围紧张、压抑但克制,像密闭空间里的情绪对抗。柔和的自然光洒入车内,营造出暗淡的橄榄绿和棕色调,并带有细微的胶片颗粒感 + +* +* +* + +* + +| 生成上一个镜头 | +|---| +基于【@视频】生成上一个镜头:[描述镜头内容] + +* +基于【@视频】,生成前一个镜头:镜头向右移动跟拍身穿黑色西服的中老年男性,走向画面右侧的主驾驶门。然后中老年男性左手先拉开车门,然后坐进驾驶位,车轻微晃动。然后画面左侧前景的年轻男性一边开口说话一边看向中老年男性。 + +* +* +* + +* +| 参考视频运镜 | +|---| +将【@视频】的运镜方式运用到【@图片】上 + +* + +把【@图片1】作为首帧,并把【@视频】的运镜运用到【@图片1】上 +* + + + +* +* + +| 参考视频动作 | +|---| +让【@图片】使用【@视频】中 [在动作的角色] 相同的动作,运动起来 + +* + + +参考使用【@视频】中女孩的动作,让【@图片1】的女孩动起来 +* + +* + + + +首尾帧 +图生视频首尾帧 + +| 技能及 Prompt 撰写格式 | 输入视频/图片/主体 | C端Prompt | C端生成效果 | B端请求体 | B端生成效果 | +|---|---|---|---|---|---| +| 首帧生视频 | | | | | | +固定【@图片】作为首帧,【描述变化内容】 +* + +【@图片1】 固定为首帧,小男孩拿起牛奶用吸管喝了一口,露出微笑。 +* + +* +* + +| 首尾帧 | +|---| +固定【@图片1】作为首帧,【@图片2】作为尾帧,【描述过渡方式】。 +** +【@图片1】固定为首帧,【@图片2】 固定为尾帧, 【@图片1 】中人物往前跑动变成 【@图片2 】。 +* + + +* +* +多镜头和单镜头 +多镜头效果的图生视频 + +多镜头效果的文生视频 + +单镜头文生视频 + +| C端Prompt | C端生成效果 | B端请求体 | B端生成效果 | +|---|---|---|---| +| 美式卡通风格的动画视频。在一个阳光明媚的夏日午后,广阔的绿色山坡上野花盛开,天空湛蓝,飘浮着白云。两个8到10岁的小男孩,身穿休闲的T恤、短裤,头戴棒球帽,在山坡上追逐蝴蝶。镜头首先是一个广角全景展示他们在起伏的草地上奔跑,随后切换到低机位特写,捕捉他们挥舞捕虫网时坚定而夸张的面部表情。其中一个男孩跳起捕捉蝴蝶,另一个兴奋地指着远方。此时,画面背景的道路上出现了一辆汽车。随着镜头跟随汽车从远处驶近,男孩们停下了动作,拿着捕虫网,好奇地注视着这辆车。汽车最终停在男孩们身边,扬起一阵轻微的尘土,男孩们依然保持着好奇张望的姿势。光影鲜明多彩,充满了夏日冒险的快乐氛围。 | * | | | + +* +* + + + +FAQ +生成视频时长(duration)什么情况支持、什么情况不支持? +文生,图生(不含首尾帧):可选5s/10s +有视频输入(video_list不为空)且 使用视频编辑功能(类型=base)时:不可指定时长,跟视频对齐 +其他情况(不传视频+传图片+主体进行生视频,或者 传视频+视频类型=feature时),可选3-10s +怎么进行视频延长? +可以通过“视频参考”来实现,传入一段视频,通过prompt驱动模型“生成下一个镜头”或者“生成上一个镜头” + +生成视频宽高比(aspect_ratio)什么情况支持、什么情况不支持? +不支持:指令变换(视频编辑),图生视频(包括首尾帧) +支持:文生视频,图片/主体参考,视频参考-其他,视频参考-生成下一个/上一个镜头 + +3-2【Omni-Video】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/omni-video/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 文生视频的任务ID | +请求路径参数,直接将值填写在请求路径中,与external_task_id两种查询方式二选一 +| external_task_id | string | 可选 | 无 | 文生视频的自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-3【Omni-Video】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/omni-video | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] + +请求体 +无 +响应体 + + +3-4【文生视频】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/text2video | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v1 | 模型名称 | +枚举值:kling-v1, kling-v1-6, kling-v2-master, kling-v2-1-master, kling-v2-5-turbo, kling-v2-6, kling-v3 +| multi_shot | boolean | 可选 | false | 是否生成多镜头视频 | +|---|---|---|---|---| +当前参数为true时,prompt参数无效 +当前参数为false时,shot_type参数及multi_prompt参数无效 +| shot_type | string | 可选 | 空 | 分镜方式 | +|---|---|---|---|---| +枚举值:customize,intelligence +当multi_shot参数为true时,当前参数必填 +| prompt | string | 可选 | 空 | 文本提示词,可包含正向描述和负向描述 | +|---|---|---|---|---| +可将提示词模板化来满足不同的视频生成需求 +Omni模型可通过Prompt与主体、图片、视频等内容实现多种能力 +通过<<<>>>的格式来指定某个主体、图片、视频,如:<<>>、<<>>、<<>> +更多信息详见:可灵视频 3.0 模型使用指南 +不能超过2500个字符 +用<<>>来指定音色,序号同voice_list参数所引用音色的排列顺序 +一次视频生成任务至多引用2个音色;指定音色时,sound参数值必须为on +语法结构越简单越好,如:男人<<>>说:“你好” +当voice_list参数不为空且prompt参数中引用音色ID时,视频生成任务按“有指定音色”计量计费 +当multi_shot参数为false或当shot_type参数为intelligence时,当前参数必填 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| multi_prompt | array | 可选 | 空 | 各分镜提示词,可包含正向描述和负向描述 | +|---|---|---|---|---| +通过index、prompt、duration参数定义分镜序号及相应提示词和时长,其中: +最多支持6个分镜,最小支持1个分镜 +每个分镜相关内容的最大长度不超过512 +每个分镜的时长不大于当前任务的总时长,不小于1 +所有分镜的时长之和等于当前任务的总时长 +用key:value承载,如下: + +当multi-shot参数为true且shot-type参数为customize时,当前参数不得为空 +| negative_prompt | string | 可选 | 空 | 负向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| voice_list | array | 可选 | 无 | 生成视频时所引用的音色的列表 | +|---|---|---|---|---| +一次视频生成任务至多引用2个音色 +当voice_list参数不为空且prompt参数中引用音色ID时,视频生成任务按“有指定音色”计量计费 +voice_id参数值通过音色定制接口返回,也可使用系统预置音色,详见音色定制相关API;非对口型API的voice_id +用key:value承载,如下: + +| sound | string | 可选 | off | 生成视频时是否同时生成声音 | +|---|---|---|---|---| +枚举值:on,off +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| cfg_scale | float | 可选 | 0.5 | 生成视频的自由度;值越大,模型自由度越小,与用户输入的提示词相关性越强 | +|---|---|---|---|---| +取值范围:[0, 1] +kling-v2.x模型不支持当前参数 +| mode | string | 可选 | std | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| camera_control | object | 可选 | 空 | 控制摄像机运动的协议(如未指定,模型将根据输入的文本/图片进行智能匹配) | +|---|---|---|---|---| +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| camera_control | +|---| +type +string +可选 +无 +预定义的运镜类型 +枚举值:"simple", "down_back", "forward_up", "right_turn_forward", "left_turn_forward" +simple:简单运镜,此类型下可在"config"中六选一进行运镜 +down_back:镜头下压并后退 ➡️ 下移拉远,此类型下config参数无需填写 +forward_up:镜头前进并上仰 ➡️ 推进上移,此类型下config参数无需填写 +right_turn_forward:先右旋转后前进 ➡️ 右旋推进,此类型下config参数无需填写 +left_turn_forward:先左旋并前进 ➡️ 左旋推进,此类型下config参数无需填写 +| camera_control | +|---| +config +object +可选 +无 +包含六个字段,用于指定摄像机在不同方向上的运动或变化 +当运镜类型指定simple时必填,指定其他类型时不填 +以下参数6选1,即只能有一个参数不为0,其余参数为0 +| config | +|---| +horizontal +float +可选 +无 +水平运镜,控制摄像机在水平方向上的移动量(沿x轴平移) +取值范围:[-10, 10],负值表示向左平移,正值表示向右平移 +| config | +|---| +vertical +float +可选 +无 +垂直运镜,控制摄像机在垂直方向上的移动量(沿y轴平移) +取值范围:[-10, 10],负值表示向下平移,正值表示向上平移 +| config | +|---| +pan +float +可选 +无 +水平摇镜,控制摄像机在水平面上的旋转量(绕y轴旋转) +取值范围:[-10, 10],负值表示绕y轴向左旋转,正值表示绕y轴向右旋转 +| config | +|---| +tilt +float +可选 +无 +垂直摇镜,控制摄像机在垂直面上的旋转量(沿x轴旋转) +取值范围:[-10, 10],负值表示绕x轴向下旋转,正值表示绕x轴向上旋转 +| config | +|---| +roll +float +可选 +无 +旋转运镜,控制摄像机的滚动量(绕z轴旋转) +取值范围:[-10, 10],负值表示绕z轴逆时针旋转,正值表示绕z轴顺时针旋转 +| config | +|---| +zoom +float +可选 +无 +变焦,控制摄像机的焦距变化,影响视野的远近 +取值范围:[-10, 10],负值表示焦距变长、视野范围变小,正值表示焦距变短、视野范围变大 +| aspect_ratio | string | 可选 | 16:9 | 生成视频的画面纵横比(宽:高) | +|---|---|---|---|---| +枚举值:16:9, 9:16, 1:1 +| duration | string | 可选 | 5 | 生成视频时长,单位s | +|---|---|---|---|---| +枚举值:3,4,5,6,7,8,9,10,11,12,13,14,15 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +调用示例 +多镜头效果的文生视频 + + +3-5【文生视频】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/text2video/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 文生视频的任务ID | +请求路径参数,直接将值填写在请求路径中,与external_task_id两种查询方式二选一 +| external_task_id | string | 可选 | 无 | 文生视频的自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-6【文生视频】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/text2video | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/videos/text2video?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-7【图生视频】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/image2video | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +请您注意,为了保持命名统一,原 model字段变更为 model_name字段,未来请您使用该字段来指定需要调用的模型版本。 + +同时,我们保持了行为上的向前兼容,如您继续使用原 model字段,不会对接口调用有任何影响、不会有任何异常,等价于 model_name为空时的默认行为(即调用V1模型) +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v1 | 模型名称 | +枚举值:kling-v1, kling-v1-5, kling-v1-6, kling-v2-master, kling-v2-1, kling-v2-1-master, kling-v2-5-turbo, kling-v2-6, kling-v3 +| image | string | 可选 | 空 | 参考图像 | +|---|---|---|---|---| +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 +image参数与image_tail参数至少二选一,二者不能同时为空 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| image_tail | string | 可选 | 空 | 参考图像 - 尾帧控制 | +|---|---|---|---|---| +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px +image参数与image_tail参数至少二选一,二者不能同时为空 +image_tail参数、dynamic_masks/static_mask参数、camera_control参数三选一,不能同时使用 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| multi_shot | boolean | 可选 | false | 是否生成多镜头视频 | +|---|---|---|---|---| +当前参数为true时,prompt参数无效,且不支持设定首尾帧生视频 +当前参数为false时,shot_type参数及multi_prompt参数无效 +| shot_type | string | 可选 | 空 | 分镜方式 | +|---|---|---|---|---| +枚举值:customize,intelligence +当multi_shot参数为true时,当前参数必填 +| prompt | string | 可选 | 空 | 文本提示词,可包含正向描述和负向描述 | +|---|---|---|---|---| +可将提示词模板化来满足不同的视频生成需求 +Omni模型可通过Prompt与主体、图片、视频等内容实现多种能力 +通过<<<>>>的格式来指定某个主体、图片、视频,如:<<>>、<<>>、<<>> +更多信息详见:可灵视频 3.0 模型使用指南 +不能超过2500个字符 +用<<>>来指定音色,序号同voice_list参数所引用音色的排列顺序 +一次视频生成任务至多引用2个音色;指定音色时,sound参数值必须为on +语法结构越简单越好,如:男人<<>>说:“你好” +当voice_list参数不为空且prompt参数中引用音色ID时,视频生成任务按“有指定音色”计量计费 +当multi_shot参数为false或当shot_type参数为intelligence时,当前参数必填 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| multi_prompt | array | 可选 | 空 | 各分镜信息,如提示词、时长等 | +|---|---|---|---|---| +通过index、prompt、duration参数定义分镜序号及相应提示词和时长,其中: +最多支持6个分镜,最小支持1个分镜 +每个分镜相关内容的最大长度不超过512 +每个分镜的时长不大于当前任务的总时长,不小于1 +所有分镜的时长之和等于当前任务的总时长 +用key:value承载,如下: + +当mult_shot参数为true且shot_type参数为customize时,当前参数不得为空 +| negative_prompt | string | 可选 | 空 | 负向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| element_list | array | 可选 | 空 | 参考主体列表 | +|---|---|---|---|---| +基于主体库中主体的ID配置,用key:value承载,如下: + +最多支持3个参考主体 +主体分为视频定制主体(简称:视频角色主体)和图片定制主体(简称:多图主体),适用范围不同,请注意区分 +更多主体信息详见:可灵「主体库 3.0」使用指南 +element_list参数与voice_list参数互斥,不能共存 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| voice_list | array | 可选 | 无 | 生成视频时所引用的音色的列表 | +|---|---|---|---|---| +一次视频生成任务至多引用2个音色 +当voice_list参数不为空且prompt参数中引用音色ID时,视频生成任务按“有指定音色”计量计费 +voice_id参数值通过音色定制接口返回,也可使用系统预置音色,详见音色定制相关API;非对口型API的voice_id +element_list参数与voice_list参数互斥,不能共存 +用key:value承载,如下: + +```json +[ + { + "voice_id": "your_voice_id" + } +] +``` + +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| sound | string | 可选 | off | 生成视频时是否同时生成声音 | +|---|---|---|---|---| +枚举值:on,off +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| cfg_scale | float | 可选 | 0.5 | 生成视频的自由度;值越大,模型自由度越小,与用户输入的提示词相关性越强 | +|---|---|---|---|---| +取值范围:[0, 1] +kling-v2.x模型不支持当前参数 +| mode | string | 可选 | std | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| static_mask | string | 可选 | 无 | 静态笔刷涂抹区域(用户通过运动笔刷涂抹的 mask 图片) | +|---|---|---|---|---| +“运动笔刷”能力包含“动态笔刷 dynamic_masks”和“静态笔刷 static_mask”两种 +支持传入图片Base64编码或图片URL(确保可访问,格式要求同 image 字段) +图片格式支持.jpg / .jpeg / .png +图片长宽比必须与输入图片相同(即image字段),否则任务失败(failed) +static_mask 和 dynamic_masks.mask 这两张图片的分辨率必须一致,否则任务失败(failed) +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| dynamic_masks | array | 可选 | 无 | 动态笔刷配置列表 | +|---|---|---|---|---| +可配置多组(最多6组),每组包含“涂抹区域 mask”与“运动轨迹 trajectories”序列 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| dynamic_masks | +|---| +mask +string +可选 +无 +动态笔刷涂抹区域(用户通过运动笔刷涂抹的 mask 图片) +支持传入图片Base64编码或图片URL(确保可访问,格式要求同 image 字段) +图片格式支持.jpg / .jpeg / .png +图片长宽比必须与输入图片相同(即image字段),否则任务失败(failed) +static_mask 和 dynamic_masks.mask 这两张图片的分辨率必须一致,否则任务失败(failed) +| dynamic_masks | +|---| +trajectories +array +可选 +无 +运动轨迹坐标序列 +生成5s的视频,轨迹长度不超过77,即坐标个数取值范围:[2, 77] +轨迹坐标系,以图片左下角为坐标原点 +注1:坐标点个数越多轨迹刻画越准确,如只有2个轨迹点则为这两点连接的直线 +注2:轨迹方向以传入顺序为指向,以最先传入的坐标为轨迹起点,依次链接后续坐标形成运动轨迹 +| dynamic_masks | +|---| +trajectories +x +int +可选 +无 +轨迹点横坐标(在像素二维坐标系下,以输入图片image左下为原点的像素坐标) +| dynamic_masks | +|---| +trajectories +y +int +可选 +无 +轨迹点纵坐标(在像素二维坐标系下,以输入图片image左下为原点的像素坐标) +| camera_control | object | 可选 | 空 | 控制摄像机运动的协议(如未指定,模型将根据输入的文本/图片进行智能匹配) | +|---|---|---|---|---| +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| camera_control | +|---| +type +string +可选 +无 +预定义的运镜类型 +枚举值:"simple", "down_back", "forward_up", "right_turn_forward", "left_turn_forward" +simple:简单运镜,此类型下可在"config"中六选一进行运镜 +down_back:镜头下压并后退 ➡️ 下移拉远,此类型下config参数无需填写 +forward_up:镜头前进并上仰 ➡️ 推进上移,此类型下config参数无需填写 +right_turn_forward:先右旋转后前进 ➡️ 右旋推进,此类型下config参数无需填写 +left_turn_forward:先左旋并前进 ➡️ 左旋推进,此类型下config参数无需填写 +| camera_control | +|---| +config +object +可选 +无 +包含六个字段,用于指定摄像机在不同方向上的运动或变化 +当运镜类型指定simple时必填,指定其他类型时不填 +以下参数6选1,即只能有一个参数不为0,其余参数为0 +| config | +|---| +horizontal +float +可选 +无 +水平运镜,控制摄像机在水平方向上的移动量(沿x轴平移) +取值范围:[-10, 10],负值表示向左平移,正值表示向右平移 +| config | +|---| +vertical +float +可选 +无 +垂直运镜,控制摄像机在垂直方向上的移动量(沿y轴平移) +取值范围:[-10, 10],负值表示向下平移,正值表示向上平移 +| config | +|---| +pan +float +可选 +无 +水平摇镜,控制摄像机在水平面上的旋转量(绕y轴旋转) +取值范围:[-10, 10],负值表示绕y轴向左旋转,正值表示绕y轴向右旋转 +| config | +|---| +tilt +float +可选 +无 +垂直摇镜,控制摄像机在垂直面上的旋转量(沿x轴旋转) +取值范围:[-10, 10],负值表示绕x轴向下旋转,正值表示绕x轴向上旋转 +| config | +|---| +roll +float +可选 +无 +旋转运镜,控制摄像机的滚动量(绕z轴旋转) +取值范围:[-10, 10],负值表示绕z轴逆时针旋转,正值表示绕z轴顺时针旋转 +| config | +|---| +zoom +float +可选 +无 +变焦,控制摄像机的焦距变化,影响视野的远近 +取值范围:[-10, 10],负值表示焦距变长、视野范围变小,正值表示焦距变短、视野范围变大 +| duration | string | 可选 | 5 | 生成视频时长,单位s | +|---|---|---|---|---| +枚举值:3,4,5,6,7,8,9,10,11,12,13,14,15 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +场景调用示例 +多镜头效果的图生视频 + +引用主体及主体音色的图生视频 + +指定音色生成视频 + +音色定制 + +3-8【图生视频】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/image2video/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 图生视频的任务ID | +请求路径参数,直接将值填写在请求路径中,与external_task_id两种查询方式二选一 +| external_task_id | string | 可选 | 无 | 图生视频的自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + + +3-9【图生视频】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/image2video | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/videos/image2video?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + + +3-10【多图参考生视频】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-image2video | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v1-6 | 模型名称 | +枚举值:kling-v1-6 +| image_list | array | 必须 | 空 | 参考图像列表 | +|---|---|---|---|---| +最多支持4张图片,用key:value承载,如下: + +API端无裁剪逻辑,请直接上传已选主体后的图片 +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于200px,图片宽高比要在1:2.5 ~ 2.5:1之间 +| prompt | string | 必须 | 无 | 正向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| negative_prompt | string | 可选 | 空 | 负向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| mode | string | 可选 | std | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| duration | string | 可选 | 5 | 生成视频时长,单位s | +|---|---|---|---|---| +枚举值:5,10 +| aspect_ratio | string | 可选 | 16:9 | 生成图片的画面纵横比(宽:高) | +|---|---|---|---|---| +枚举值:16:9, 9:16, 1:1 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +3-11【多图参考生视频】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-image2video/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 多图参考生视频的任务ID | +请求路径参数,直接将值填写在请求路径中,与external_task_id两种查询方式二选一 +| external_task_id | string | 可选 | 无 | 多图参考生视频的自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-12【多图参考生视频】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-image2video | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/videos/multi-image2video?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-13【动作控制】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/motion-control | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v2-6 | 模型名称 | +枚举值:kling-v2-6, kling-v3 +| prompt | string | 可选 | 空 | 文本提示词,可包含正向描述和负向描述 | +|---|---|---|---|---| +可通过提示词为画面增加元素、实现运镜效果等,详见可灵「动作控制」使用指南 +不能超过2500个字符 +| image_url | string | 必须 | 无 | 参考图像,生成视频中的人物、背景等元素均已参考图为准 | +|---|---|---|---|---| +视频内容需满足以下要求: +人物比例尽量与参考动作比例一致,尽量避免全身动作驱动半身人物进行生成 +人物需要漏出清晰的上半身或全身的肢体及头部,避免遮挡 +画面中人物避免存在极端朝向,比如倒立、平卧等。人物占画面比例不得太低 +支持真实/风格化的角色(包括人物/类人动物/部分纯动物/部分类人肢体比例的角色)通过 +包含支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸介于300px~65536px,图片宽高比介于1:2.5 ~ 2.5:1之间 +| video_url | string | 必须 | 无 | 参考视频的获取链接。生成视频中的人物动作与参考视频一致。 | +|---|---|---|---|---| +视频内容需满足以下要求: +人物需要漏出清晰的上半身或全身的全部肢体及头部,避免遮挡 +建议上传1人动作视频,2人及以上会取画面占比最大的人物动作进行生成 +推荐使用真人动作,部分风格化的人物/类人肢体比例可以通过 +动作视频一镜到底,角色始终出现在画面中,避免切镜、运镜等。否则会被截取 +动作避免过快,相对平稳的动作生成效果更佳 +视频文件支持.mp4/.mov,文件大小不超过100MB,仅支持长宽的边长均位于340px~3850px之间,上述校验不通过会返回错误码等信息 +视频时长下限不短于3秒,时长上限与人物朝向参考(character_orientation)有关: +当人物朝向与视频中人物一致时,视频时长最长可达30秒; +当人物朝向与图片中人物一致时,视频时长最长可达10秒; +如果您的动作难度比较高、速度比较快,有一定概率生成不足上传视频时长的结果,因为模型只能提取有效动作时长进行生成,最短提取出3s可用连续动作即可生成。请注意,因此消耗的积分将无法退还,建议适当调整动作难度与速度 +积分扣减计算以输出视频时长为准 +系统会校验视频内容,如有问题会返回错误码等信息 +| element_list | array | 可选 | 空 | 主体参考列表 | +|---|---|---|---|---| +基于主体库中主体的ID配置,用key:value承载,如下: + +引用主体时,生成的视频暂时只能参考视频中的人物朝向 +暂时仅支持引入1个主体 +| keep_original_sound | string | 可选 | yes | 可选择是否保留视频原声 | +|---|---|---|---|---| +枚举值:yes,no +其中yes:保留视频原声 +其中no:不保留视频原声 +| character_orientation | string | 必须 | 无 | 生成视频中人物的朝向,可选择与图片一致或与视频一致 | +|---|---|---|---|---| +枚举值:image,video,其中: +其中image:与图片中人物朝向一致;此时参考视频时长不得超过10秒; +其中video:与视频中人物朝向一致;此时参考视频时长不得超过30秒; +引用主体时,生成的视频暂时只能参考视频中的人物朝向 +| mode | string | 必选 | 无 | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 空 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +场景调用示例 + + +3-14【动作控制】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/motion-control/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 视频生成的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +请求体 +无 +响应体 + +3-15【动作控制】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/motion-control | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/videos/motion-control?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + +3-16【多模态视频编辑】初始化待编辑视频 +操作指引:使用“多模态视频编辑”功能时,需先对原始视频进行初始化处理。其中,在替换或删除现有视频中的元素时,需先标记视频中相关元素。 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/init-selection | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| video_id | string | 可选 | 空 | 视频ID,从历史作品中选择待编辑的视频,仅支持仅30天时间生成的视频作品 | +仅支持时长≥2秒且≤5秒,或≥7秒且≤10秒的视频 +与video_url参数相关,不能同时为空,也不能同时有值 +| video_url | string | 可选 | 空 | 获取视频的URL,上传时传视频下载链接,编辑选区时传接口返回的视频URL | +|---|---|---|---|---| +仅支持MP4和MOV格式 +仅支持时长≥2秒且≤5秒,或≥7秒且≤10秒的视频 +视频宽高尺寸需介于720px(含)和2160px(含)之间 +仅支持上传24、30或60fps的视频 +与video_url参数相关,不能同时为空,也不能同时有值 +响应体 + + +3-17【多模态视频编辑】增加视频选区 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/add-selection | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| session_id | string | 必须 | 无 | 会话ID,会基于视频初始化任务生成,不会随编辑选区行为而改变 | +| frame_index | | | | | + +int +必须 +无 +帧号 +最多支持添加10个标记帧,即最多基于10帧标记视频选区 +1次仅支持标记1帧 +| points | object[] | 必须 | 无 | 点选坐标,用x、y表示 | +|---|---|---|---|---| +取值范围:[0,1],用百分比表示;[0,1]代表画面左上角 +支持同时增加多个标记点,某一帧最多可标记10个点 +响应体 + +示例代码 +解析图像分割结果 + +绘制图像分割图层 + + +3-18【多模态视频编辑】删减视频选区 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/delete-selection | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| session_id | string | 必须 | 无 | 会话ID,会基于视频初始化任务生成,不会随编辑选区行为而改变 | +| frame_index | int | 必须 | 无 | 帧号 | +| points | object[] | 必须 | 无 | 点选坐标,用x、y表示 | +取值范围:[0,1],用百分比表示;[0,1]代表画面左上角 +支持同时增加多个标记点 +坐标点需与增加视频选区时完全一致 +响应体 + + +3-19【多模态视频编辑】清除视频选区 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/clear-selection | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| session_id | string | 必须 | 无 | 会话ID,会基于视频初始化任务生成,不会随编辑选区行为而改变 | + +响应体 + + +3-20【多模态视频编辑】预览已选区视频 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/preview-selection | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| session_id | string | 必须 | 无 | 会话ID,会基于视频初始化任务生成,不会随编辑选区行为而改变 | +响应体 + + +3-21【多模态视频编辑】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/ | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kling-v1-6 | 模型名称 | +枚举值:kling-v1-6 +| session_id | string | 必须 | 无 | 会话ID | +|---|---|---|---|---| +会基于视频初始化任务生成,不会随编辑选区行为而改变 +| edit_mode | string | 必须 | 无 | 操作类型 | +|---|---|---|---|---| +枚举值:addition, swap, removal, 其中: +addition:增加元素 +swap:替换元素 +removal:删除元素 +| image_list | array | 可选 | 空 | 裁剪后的参考图像 | +|---|---|---|---|---| +增加视频元素时:当前参数必填,可上传1~2张图片 +编辑视频元素时:当前参数必填,仅可上传1张图片 +删除视频元素时,当前参数无需填写 +用key:value承载,如下: + +API端无裁剪逻辑,请直接上传已选主体后的图片 +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px +| prompt | string | 必须 | 无 | 正向文本提示词 | +|---|---|---|---|---| +用<<>>的格式来特指某个视频或某张图片,如<<>>、<<>> +为保证效果,提示词中需包含视频编辑所需的视频和图片(如有),如下文“推荐的Prompt模板” +不能超过2500个字符 +推荐的Prompt模板 +增加元素 +中文:基于<<>>中的原始内容,以自然生动的方式,将<<>>中的【】,融入<<>>的【】 +英文:Using the context of <<>>, seamlessly add [x] from <<>> +替换元素 +中文:使用<<>>中的 【】,替换<<>>中的 【】 +英文:swap [x] from <<>> for [x] from <<>> +删除元素 +中文:删除<<>>中的【】 +英文:Delete [x] from <<>> +注:中文的【】,英文的[x],是需要用户填写的部分 + +| negative_prompt | string | 可选 | 空 | 负向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| mode | string | 可选 | std | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +| duration | string | 可选 | 5 | 生成视频时长,单位s | +|---|---|---|---|---| +枚举值:5,10 +支持且仅支持生成5s和10s的视频,对于生成不同时长的视频,对输入视频有时长会有所限制: +如生成5s时长视频,输入视频时长需≥2s且≤5s +如生成10s时长视频,输入视频时长需≥7s且≤10s +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 空 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +3-22【多模态视频编辑】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 多图参考生视频的任务ID | +请求路径参数,直接将值填写在请求路径中,与external_task_id两种查询方式二选一 +| external_task_id | string | 可选 | 无 | 多图参考生视频的自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-23【多模态视频编辑】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/multi-elements/ | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/videos/multi-image2video?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-24【视频延长】创建任务 +注-1:视频延长是指对文生/图生视频结果进行时间上的延长,单次可延长4~5s,使用的模型和模式不可选择、与源视频相同 +注-2:被延长后的视频可以再次延长,但总视频时长不能超过3min +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/video-extend | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| video_id | string | 必须 | 无 | 视频ID | +支持通过文本、图片和视频延长生成的视频的ID(原视频不能超过3分钟) +请注意,基于目前的清理策略、视频生成30天之后会被清理,则无法进行延长 +| prompt | string | 可选 | 无 | 正向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| negative_prompt | string | 可选 | 无 | 负向文本提示词 | +|---|---|---|---|---| +不能超过2500个字符 +| cfg_scale | float | 可选 | 0.5 | 提示词参考强度 | +|---|---|---|---|---| +取值范围:[0,1],数值越大参考强度越大 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +3-25【视频延长】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/video-extend/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 视频续写的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +请求体 +无 +响应体 + + +3-26【视频延长】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/video-extend | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-27【数字人】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/avatar/image2video | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| image | string | 必须 | 无 | 数字人参考图 | +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 +| audio_id | string | 可选 | 空 | 通过试听接口生成的音频的ID | +|---|---|---|---|---| +仅支持使用30天内生成的、时长不短于2秒且不超过300秒的音频 +audio_id、sound_file参数二选一,不能同时为空,也不能同时有值 +| sound_file | string | 可选 | 空 | 音频文件 | +|---|---|---|---|---| +支持传入音频Base64编码或图音频URL(确保可访问) +音频文件支持.mp3/.wav/.m4a/.aac,文件大小不超过5MB,格式不匹配或文件过大会返回错误码等信息 +仅支持使用时长不短于2秒且不长于300秒的音频 +audio_id、sound_file参数二选一,不能同时为空,也不能同时有值 +系统会校验音频内容,如有问题会返回错误码等信息 +| prompt | string | 可选 | 空 | 正向文本提示词 | +|---|---|---|---|---| +可定义数字人动作、情绪及运镜等 +不能超过2500个字符 +| mode | string | 可选 | std | 生成视频的模式 | +|---|---|---|---|---| +枚举值:std,pro +其中std:标准模式(标准),基础模式,性价比高 +其中pro:专家模式(高品质),高表现模式,生成视频质量更佳 +不同模型版本、视频模式支持范围不同,详见当前文档3-0能力地图 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +3-28【数字人】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/avatar/image2video/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 数字人的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +请求体 +无 +响应体 + + +3-29【数字人】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/avatar/image2video | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-30【对口型】人脸识别 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/identify-face | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| video_id | string | 可选 | 空 | 通过可灵AI生成的视频的ID | +用于指定视频、判断视频是否可用于对口型服务 +与video_url参数二选一填写,不能同时为空,也不能同时有值 +仅支持使用30天内生成的时长不超过60秒的视频 +| video_url | string | 可选 | 空 | 所上传视频的获取URL | +|---|---|---|---|---| +用于指定视频,并判断视频是否可用于对口型服务 +与video_id参数二选一填写,不能同时为空,也不能同时有值 +视频文件支持.mp4/.mov,文件大小不超过100MB,视频时长不超过60s且不短于2s,仅支持720p和1080p、长宽的边长均位于512px~2160px之间,上述校验不通过会返回错误码等信息 +系统会校验视频内容,如有问题会返回错误码等信息 + +响应体 + + +3-31【对口型】创建任务 +对口型创建任务接口已升级至全新版本,如需浏览旧版请移步可灵AI【对口型】(旧版)API文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/advanced-lip-sync | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| session_id | string | 必须 | 无 | 会话ID,会基于对口型人脸识别接口生成 | +| face_choose | | | | | + +string[] +必填 +无 +指定人脸对口型 +包括人脸ID、口型参考等内容等 +暂时仅支持指定单人对口型 +| face_choose | +|---| +face_id +string +必填 +无 +人脸ID +由人脸识别接口返回 +| face_choose | +|---| +audio_id +string +可选 +空 +通过试听接口生成的音频的ID +仅支持使用30天内生成的、时长不短于2秒且不超过60秒的音频 +audio_id、sound_file参数二选一,不能同时为空,也不能同时有值 +| face_choose | +|---| +sound_file +string +可选 +空 +音频文件 +支持传入音频Base64编码或图音频URL(确保可访问) +音频文件支持.mp3/.wav/.m4a/.aac,文件大小不超过5MB,格式不匹配或文件过大会返回错误码等信息 +仅支持使用时长不短于2秒且不长于60秒的音频 +audio_id、sound_file参数二选一,不能同时为空,也不能同时有值 +系统会校验音频内容,如有问题会返回错误码等信息 +| face_choose | +|---| +sound_start_time +long +必须 +无 +音频裁剪起点时间 +以原始音频开始时间为准,开始时间为0分0秒,单位ms +起点之前的音频会被裁剪,裁剪后音频不得短于2秒 +| face_choose | +|---| +sound_end_time +long +必须 +无 +音频裁剪终点时间 +以原始音频开始时间为准,开始时间为0分0秒,单位ms +终点之后的音频会被裁剪,裁剪后音频不得短于2秒 +终点时间不得晚于原始音频总时长 +| face_choose | +|---| +sound_insert_time +long +必须 +无 +裁剪后音频插入时间 +以视频开始时间为准,视频开始时间为0分0秒,单位ms +插入音频的时间范围与该人脸可对口型时间区间至少重合2秒时长 +插入音频的开始时间不得早于视频开始时间,插入音频的结束时间不得晚于视频结束时间 +| face_choose | +|---| +sound_volume +float +可选 +1 +音频音量大小;值越大,音量越大 +取值范围:[0, 2] +| face_choose | +|---| +original_audio_volume +float +可选 +1 +原始视频音量大小;值越大,音量越大 +取值范围:[0, 2] +原视频无声时,当前参数无效果 +| watermark_info | array | 可选 | 空 | 是否同时生成含水印的结果 | +|---|---|---|---|---| +通过enabled参数定义,具体array格式如下: + +```json +{ + "enabled": true +} +``` + +暂不支持自定义水印 +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” + +响应体 + + +3-32【对口型】查询任务(单个) +对口型创建任务接口已升级至全新版本,如需浏览旧版请移步可灵AI【对口型】(旧版)API文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/advanced-lip-sync/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 对口型的任务ID | +请求路径参数,直接将值填写在请求路径中 +请求体 +无 +响应体 + + +3-33【对口型】查询任务(列表) +对口型创建任务接口已升级至全新版本,如需浏览旧版请移步可灵AI【对口型】(旧版)API文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/advanced-lip-sync | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + +3-34【视频特效】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/effects | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +通用请求体 +当前一共支持 230 款特效,您可以根据调用 effect_scene 实现不同的效果,详细内容请见:特效模版中心 + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| effect_scene | string | 必须 | 无 | 场景名称 | +枚举值:flash_drive, shush_my_dreams, french_elegance, finger_swipe, advent_of_flora, smooth_transition, raid_check, fortune_in_motion, chinese_trend, sedan_chair_dance, yangge_dance, good_luck_dance, laicai_dance, snow_night_kiss, eternal_kiss, color_mixing, palm_sized_figure, lantern_festival_cuju, unique_firework, unique_spring_couplets, horse_mask, fortune_knocks_cartoon, tangyuan_to_animal, hot_feet_dance, swag_dance, pigeon_dance, bloodline_dance, chanel_dance, cute_dance, love_theme_song, pumpitup_dance, city_to_village, fortune_god_transform, new_year_feast, ring_in_new, horse_year_firework, pet_vlogger, crystal_horse, lateral_shift_transition, drunk_dance, drunk_dance_pet, daoma_dance, bouncy_dance, smooth_sailing_dance, new_year_greeting, lion_dance, prosperity, great_success, golden_horse_fortune, red_packet_box, lucky_horse_year, lucky_red_packet, lucky_money_come, lion_dance_pet, dumpling_making_pet, fish_making_pet, pet_red_packet, lantern_glow, expression_challenge, overdrive, heart_gesture_dance, poping, martial_arts, running, nezha, motorcycle_dance, subject_3_dance, ghost_step_dance, phantom_jewel, zoom_out, cheers_2026, kiss_pro, fight_pro, hug_pro,heart_gesture_pro, dollar_rain_pro, pet_bee_pro, countdown_teleport, santa_random_surprise, magic_match_tree, bullet_time_360, happy_birthday, birthday_star, thumbs_up_pro, tiger_hug_pro, pet_lion_pro, surprise_bouquet, bouquet_drop, 3d_cartoon_1_pro, firework_2026, glamour_photo_shoot, box_of_joy, first_toast_of_the_year, my_santa_pic, santa_gift, steampunk_christmas, snowglobe, christmas_photo_shoot, ornament_crash​, santa_express, instant_christmas, particle_santa_surround, coronation_of_frost, building_sweater, spark_in_the_snow, scarlet_and_snow, cozy_toon_wrap, bullet_time_lite, magic_cloak, balloon_parade, jumping_ginger_joy, bullet_time, c4d_cartoon_pro, pure_white_wings, black_wings, golden_wing, pink_pink_wings, venomous_spider, throne_of_king, luminous_elf, woodland_elf, japanese_anime_1, american_comics, guardian_spirit, swish_swish, snowboarding, witch_transform, vampire_transform, pumpkin_head_transform, demon_transform, mummy_transform, zombie_transform, cute_pumpkin_transform, cute_ghost_transform, knock_knock_halloween, halloween_escape, baseball, inner_voice, a_list_look, memory_alive, trampoline, trampoline_night, pucker_up, guess_what, feed_mooncake, rampage_ape, flyer, dishwasher, pet_chinese_opera, magic_fireball, gallery_ring, pet_moto_rider, muscle_pet, squeeze_scream, pet_delivery, running_man, disappear, mythic_style, steampunk, 3d_cartoon_2, eagle_snatch, hug_from_past, firework, media_interview, pet_chef, santa_gifts, santa_hug, girlfriend, boyfriend, heart_gesture_1, pet_wizard, smoke_smoke, instant_kid, dollar_rain, cry_cry, building_collapse, gun_shot, mushroom, double_gun, pet_warrior, lightning_power, jesus_hug, shark_alert, long_hair, lie_flat, polar_bear_hug, brown_bear_hug , jazz_jazz, office_escape_plow, fly_fly, watermelon_bomb, pet_dance, boss_coming, wool_curly, pet_bee, marry_me, swing_swing, day_to_night, piggy_morph, wig_out, car_explosion, ski_ski, siblings, construction_worker, let’s_ride, snatched, magic_broom, felt_felt, jumpdrop, splashsplash, surfsurf, fairy_wing, angel_wing, dark_wing, skateskate, plushcut, jelly_press, jelly_slice, jelly_squish, jelly_jiggle, pixelpixel, yearbook, instant_film, anime_figure, rocketrocket, bloombloom, dizzydizzy, fuzzyfuzzy, squish, expansion +更多参数请见: 特效模版中心 +| input | object | 必须 | 无 | 支持不同任务输入的结构体 | +|---|---|---|---|---| +根据scene不同,结构体里传的字段不同,具体如「场景请求体」所示 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +场景请求体 +单图特效:220款,flash_drive, shush_my_dreams, advent_of_flora, raid_check, fortune_in_motion, chinese_trend, sedan_chair_dance, yangge_dance, good_luck_dance, laicai_dance, color_mixing, palm_sized_figure, lantern_festival_cuju, unique_firework, unique_spring_couplets, horse_mask, fortune_knocks_cartoon, tangyuan_to_animal, hot_feet_dance, swag_dance, pigeon_dance, bloodline_dance, chanel_dance, cute_dance, love_theme_song, pumpitup_dance, city_to_village, fortune_god_transform, new_year_feast, ring_in_new, horse_year_firework, pet_vlogger, crystal_horse, lateral_shift_transition, drunk_dance, drunk_dance_pet, daoma_dance, bouncy_dance, smooth_sailing_dance, new_year_greeting, lion_dance, prosperity, great_success, golden_horse_fortune, red_packet_box, lucky_horse_year, lucky_red_packet, lucky_money_come, lion_dance_pet, dumpling_making_pet, fish_making_pet, pet_red_packet, lantern_glow, expression_challenge, overdrive, heart_gesture_dance, poping, martial_arts, running, nezha, motorcycle_dance, subject_3_dance, ghost_step_dance, phantom_jewel, zoom_out, dollar_rain_pro, pet_bee_pro, countdown_teleport, santa_random_surprise, magic_match_tree, bullet_time_360, happy_birthday, birthday_star, thumbs_up_pro, tiger_hug_pro, pet_lion_pro, surprise_bouquet, bouquet_drop, 3d_cartoon_1_pro, firework_2026, glamour_photo_shoot, box_of_joy, first_toast_of_the_year, my_santa_pic, santa_gift, steampunk_christmas, snowglobe, christmas_photo_shoot, ornament_crash​, santa_express, instant_christmas, particle_santa_surround, coronation_of_frost, building_sweater, spark_in_the_snow, scarlet_and_snow, cozy_toon_wrap, bullet_time_lite, magic_cloak, balloon_parade, jumping_ginger_joy, bullet_time, c4d_cartoon_pro, pure_white_wings, black_wings, golden_wing, pink_pink_wings, venomous_spider, throne_of_king, luminous_elf, woodland_elf, japanese_anime_1, american_comics, guardian_spirit, swish_swish, snowboarding, witch_transform, vampire_transform, pumpkin_head_transform, demon_transform, mummy_transform, zombie_transform, cute_pumpkin_transform, cute_ghost_transform, knock_knock_halloween, halloween_escape, baseball, inner_voice, a_list_look, memory_alive, trampoline, trampoline_night, pucker_up, guess_what, feed_mooncake, rampage_ape, flyer, dishwasher, pet_chinese_opera, magic_fireball, gallery_ring, pet_moto_rider, muscle_pet, squeeze_scream, pet_delivery, running_man, disappear, mythic_style, steampunk, 3d_cartoon_2, eagle_snatch, hug_from_past, firework, media_interview, pet_chef, santa_gifts, santa_hug, girlfriend, boyfriend, heart_gesture_1, pet_wizard, smoke_smoke, instant_kid, dollar_rain, cry_cry, building_collapse, gun_shot, mushroom, double_gun, pet_warrior, lightning_power, jesus_hug, shark_alert, long_hair, lie_flat, polar_bear_hug, brown_bear_hug , jazz_jazz, office_escape_plow, fly_fly, watermelon_bomb, pet_dance, boss_coming, wool_curly, pet_bee, marry_me, swing_swing, day_to_night, piggy_morph, wig_out, car_explosion, ski_ski, siblings, construction_worker, let’s_ride, snatched, magic_broom, felt_felt, jumpdrop, splashsplash, surfsurf, fairy_wing, angel_wing, dark_wing, skateskate, plushcut, jelly_press, jelly_slice, jelly_squish, jelly_jiggle, pixelpixel, yearbook, instant_film, anime_figure, rocketrocket, bloombloom, dizzydizzy, fuzzyfuzzy, squish, expansion + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| effect_scene | string | 必须 | 无 | 场景名称 | +| image | string | 必须 | 无 | 参考图像 | +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 +单人特效请求示例 + + +双人互动特效:10款,french_elegance, finger_swipe, smooth_transition, snow_night_kiss, eternal_kiss, cheers_2026, kiss_pro, fight_pro, hug_pro, heart_gesture_pro + +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| effect_scene | string | 必须 | 无 | 场景名称 | +| images | Array[string] | 必须 | 无 | 参考图像组 | +数组的长度必须是2,上传的第一张图在合照的左边,上传的第二张图在合照的右边 +该服务包含合照功能,即用户上传两张人想图,可灵AI将自适应拼接为合照,如图所示先后上传 +"https://p2-kling.klingai.com/bs2/upload-ylab-stunt/c54e463c95816d959602f1f2541c62b2.png?x-kcdn-pid=112452", +"https://p2-kling.klingai.com/bs2/upload-ylab-stunt/5eef15e03a70e1fa80732808a2f50f3f.png?x-kcdn-pid=112452" +得到合照的效果为: + +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 + +双人特效请求示例 (每个特效的请求示例详见:特效模版中心) + + +响应体 + + +3-35【视频特效】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/effects/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 视频特效的任务ID | +请求路径参数,直接将值填写在请求路径中,与external_task_id两种查询方式二选一 +| external_task_id | string | 可选 | 无 | 视频特效的自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-36【视频特效】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/effects | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/videos/image2video?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-37 【文生音效】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/text-to-audio | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| prompt | string | 必须 | 无 | 文本提示词 | +内容长度不超过200字符 +| duration | float | 必须 | 无 | 生成音频的时长 | +|---|---|---|---|---| +取值范围: ​​3.0秒至10.0秒​​,支持小数点后一位精度 +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” + +响应体 + + +3-38【文生音效】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/text-to-audio/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 可选 | 无 | 文生音频的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 无 | 用户自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-39【文生音效】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/text-to-audio | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/audio/text-to-audio?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-40【视频生音效】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/video-to-audio | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| video_id | string | 必须 | 无 | 通过可灵AI生成的视频的ID | +与video_url参数二选一填写,不能同时为空,也不能同时有值 +仅支持30天内生成并且长度在3.0秒-20.0秒的视频 +| video_url | +|---| + +string +必须 +无 +所上传视频的获取链接 +与video_id参数二选一填写,不能同时为空,也不能同时有值 +视频格式仅支持MP4/MOV,文件大小≤100M,视频长度在3.0秒-20.0秒 +| sound_effect_prompt | string | 可选 | 无 | 音效生成提示词 | +|---|---|---|---|---| +不能超过200个字符 +| bgm_prompt | string | 可选 | 无 | 配乐生成提示词 | +|---|---|---|---|---| +不能超过200个字符 +| asmr_mode | boolean | 可选 | false | 是否开启ASMR模式;该模式会增强细节音效, 适合高沉浸内容场景 | +|---|---|---|---|---| +true表示开启,false表示关闭(默认值) +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” + +响应体 + + +3-41【视频生音效】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/video-to-audio/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 视频生音频的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 无 | 用户自定义任务ID | +|---|---|---|---|---| +创建任务时填写的external_task_id,与task_id两种查询方式二选一 +请求体 +无 +响应体 + + +3-42【视频生音效】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/video-to-audio | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/audio/video-to-audio?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-43【通用】语音合成 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/audio/tts | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| text | string | 必填 | 无 | 合成音频的文案 | +文本内容最大长度1000,内容过长会返回错误码等信息 +系统会校验文本内容,如有问题会返回错误码等信息 +| voice_id | string | 必填 | 无 | 音色ID | +|---|---|---|---|---| +系统提供多种音色可供选择,具体音色效果、音色ID、音色语种对应关系点此查看;音色试听不支持自定义文案 +音色试听文件命名规范:音色名称#音色ID#音色语种 +| voice_language | string | 必填 | 无 | 音色语种,与音色ID对应,详见 | +|---|---|---|---|---| +枚举值:zh,en +音色语种与音色ID对应,详见上文 +| voice_speed | float | 可选 | 1.0 | 语速 | +|---|---|---|---|---| +有效范围:0.8~2.0,精确至小数点后1位,超出部分将自动四舍五入 + +响应体 + + +3-44【通用】图像识别 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/videos/image-recognize | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| image | string | 必须 | 无 | 待识别的图片 | +支持传入图片Base64编码或图片URL(确保可访问) +请注意,若您使用base64的方式,请确保您传递的所有图像数据参数均采用Base64编码格式。在提交数据时,请不要在Base64编码字符串前添加任何前缀,例如data:image/png;base64,。正确的参数格式应该直接是Base64编码后的字符串。 +示例: +正确的Base64编码参数: + +错误的Base64编码参数(包含data:前缀): + +请仅提供Base64编码的字符串部分,以便系统能够正确处理和解析您的数据。 +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比介于1:2.5 ~ 2.5:1之间 + +响应体 + + +3-45【通用】创建主体 +创建主体相关服务已升级至全新版本,如需浏览旧版请移步可灵AI【旧版】主体相关API文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/advanced-custom-elements | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| element_name | string | 必须 | 无 | 主体名称 | +不能超过20个字符 +| element_description | string | 必须 | 无 | 主体描述 | +|---|---|---|---|---| +不能超过100个字符 +| reference_type | string | 必须 | 无 | 主体参考方式 | +|---|---|---|---|---| +枚举值:video_refer, image_refer +video_refer: 视频角色主体,此时将参考element_video_list定义主体外表 +image_refer: 多图主体,此时将参考element_image_list定义主体外表 +通过视频定制的主体和通过图片定制的主体的可用范围不同,详见能力地图和参数说明。 +| element_image_list | array | 可选 | 空 | 主体参考图,可通过多张图片设定主体及其细节 | +|---|---|---|---|---| +包括正面参考图和其他角度或特写参考图,其中: +至少包括1张正面参考图,由frontal_image参数定义 +需包括1~3张其他参考图,需与正面参考图有差异,由image_url参数定义 +用key:value承载,如下: + +```json +[ + { + "image_url": "https://example.com/image.jpg" + } +] +``` + +支持传入图片Base64编码或图片URL(确保可访问) +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px,图片宽高比要在1:2.5 ~ 2.5:1之间 +reference_type参数值为image_refer时,当前参数必填 +| element_video_list | array | 可选 | 空 | 主体参考视频,可通过视频设定主体及其细节 | +|---|---|---|---|---| +可上传有声视频,有声视频包含人声则触发音色定制(定制+入音色库+与主体绑定) +暂时仅支持通过视频定制写实风格的人形形象 +参考视频时当前参数必填,参考图片时当前参数无效 +用key:value承载,如下: + +```json +[ + { + "video_url": "https://example.com/video.mp4", + "refer_type": "feature", + "keep_original_sound": "yes" + } +] +``` + +视频格式仅支持MP4/MOV +仅支持时长介于3s~8s之间、宽高比例需为16:9或9:16的1080P视频 +至多仅支持上传1段视频,视频大小不超过200MB +video_url参数值不得为空 +视频定制的主体仅支持用于kling-video-o3及之后的模型 +| element_voice_id | string | 可选 | 空 | 主体音色ID,可绑定音色库中已有音色 | +|---|---|---|---|---| +当前参数为空时,当前主体不绑定音色 +为多图主体绑定音色时,仅支持人物形象主体或类人形象主体 +可通过音色相关API获取ID,详见:「可灵AI」新系统 API 接口文档 +| tag_list | array | 可选 | 空 | 为主体配置标签,一个主体可以配置多个标签 | +|---|---|---|---|---| +用key:value承载,其中具体如下: + +tag的ID与名称关系: +| ID | 名称 | +|---|---| +| o_101 | 热梗 | +| o_102 | 人物 | +| o_103 | 动物 | +| o_104 | 道具 | +| o_105 | 服饰 | +| o_106 | 场景 | +| o_107 | 特效 | +| o_108 | 其他 | + +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + +调用示例 +创建图片定制主体 + +创建视频定制主体 + + +3-46【通用】查询自定义主体(单个) +查询主体相关服务已升级至全新版本,如需浏览旧版请移步可灵AI【旧版】主体相关API文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/advanced-custom-elements/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 图片生成的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +请求体 +无 +响应体 + +调用示例 +查询某个自定义主体 + + +3-47【通用】查询自定义主体(列表) +查询主体相关服务已升级至全新版本,如需浏览旧版请移步可灵AI【旧版】主体相关API文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/advanced-custom-elements | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + +调用示例 +批量查询自定义主体 + + +3-48【通用】查询官方主体(列表) +查询主体相关服务已升级至全新版本,如需浏览旧版请移步可灵AI【旧版】主体相关API文档 + +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/advanced-presets-elements | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + +3-49【通用】删除自定义主体 +删除自定义主体相关服务已原地升级,无需移步其他文档 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/delete-elements | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 + +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| element_id | string | 必须 | 无 | 要删除的主体ID,仅支持删除自定义主体 | +响应体 + + +3-50【通用】创建自定义音色 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/custom-voices | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| voice_name | string | 必须 | 无 | 音色名称 | +文本内容最大长度20个字符 +创建后不再使用的音色可通过API删除 +| voice_url | string | 可选 | 空 | 音色数据文件获取链接 | +|---|---|---|---|---| +支持.mp3/.wav/.mp4/.mov格式的音视频文件 +音频中人生需干净无杂音,有且只能有一种人声,时长不短于5秒且不长于30秒 +| video_id | string | 可选 | 空 | 历史作品ID,可通过引用历史作品提供音频素材 | +|---|---|---|---|---| +仅满足以下条件的视频可以用于定制音色: +使用V2.6版本模型生成且开启sound参数值为on的视频 +通过数字人API生成的视频 +通过对口型API生成的视频 +音频中人生需干净无杂音,有且只能有一种人声,时长不短于5秒且不长于30秒 +| callback_url | string | 可选 | 空 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +3-51【通用】查询自定义音色(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/custom-voices/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 生成音色的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +响应体 + + +3-52 【通用】查询自定义音色(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/custom-voices | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,1000] +响应体 + + +3-53【通用】查询官方音色(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/presets-voices | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,1000] + +请求体 +无 +响应体 + + +3-54【通用】删除自定义音色 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/general/delete-voices | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| voice_id | string | 必须 | 无 | 待删除的音色的ID,仅支持删除自定义音色 | +响应体 + + +四、虚拟试穿 +4-1【虚拟试穿】创建任务 +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/kolors-virtual-try-on | +| 请求方法 | POST | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | + +请求体 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| model_name | string | 可选 | kolors-virtual-try-on-v1 | 模型名称 | +枚举值:kolors-virtual-try-on-v1, kolors-virtual-try-on-v1-5 +| human_image | string | 必须 | 无 | 上传的人物图片 | +|---|---|---|---|---| +支持传入图片Base64编码或图片URL(确保可访问) +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px +| cloth_image | string | 必须 | 无 | 虚拟试穿的服饰图片 | +|---|---|---|---|---| +支持上传服饰商品图或服饰白底图,支持上装upper、下装lower、与连体装dress +支持传入图片Base64编码或图片URL(确保可访问) +图片格式支持.jpg / .jpeg / .png +图片文件大小不能超过10MB,图片宽高尺寸不小于300px +其中 kolors-virtual-try-on-v1-5 模型不仅支持单个服装输入,还支持“上装+下装”形式服装组合输入,即: +输入单个服饰图片(上装 or 下装 or 连体装)-> 生成试穿的单品图片 +输入组合服饰图片(您可以将多个单品服饰白底图拼接到同一张图片) +模型检测为“上装+下装” -> 生成试穿的“上装+下装”图片 +模型检测为“上装+上装” -> 生成失败 +模型检测为“下装+下装” -> 生成失败 +模型检测为“连体装+连体装” -> 生成失败 +模型检测为“上装+连体装” -> 生成失败 +模型检测为“下装+连体装” -> 生成失败 +组合服饰图片示例:* +| callback_url | string | 可选 | 无 | 本次任务结果回调通知地址,如果配置,服务端会在任务状态发生变更时主动通知 | +|---|---|---|---|---| +具体通知的消息schema见“Callback协议” +| external_task_id | string | 可选 | 无 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 + +响应体 + + +4-2【虚拟试穿】查询任务(单个) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/kolors-virtual-try-on/{id} | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| task_id | string | 必须 | 无 | 虚拟试穿的任务ID | +请求路径参数,直接将值填写在请求路径中 +| external_task_id | string | 可选 | 空 | 自定义任务ID | +|---|---|---|---|---| +用户自定义任务ID,传入不会覆盖系统生成的任务ID,但支持通过该ID进行任务查询 +请注意,单用户下需要保证唯一性 +请求体 +无 +响应体 + + +4-3【虚拟试穿】查询任务(列表) +| 网络协议 | https | +|---|---| +| 请求地址 | /v1/images/kolors-virtual-try-on | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +查询参数 +/v1/images/kolors-virtual-try-on?pageNum=1&pageSize=30 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| pageNum | int | 可选 | 1 | 页码 | +取值范围:[1,1000] +| pageSize | int | 可选 | 30 | 每页数据量 | +|---|---|---|---|---| +取值范围:[1,500] +请求体 +无 +响应体 + + + +五、Callback协议 +对于异步任务(图像生成 / 视频生成 / 虚拟试穿),若您在创建任务时主动设置了callback_url,则当任务状态发生变更时、服务端会主动通知,协议如下 + + +六、账号信息查询 +6-1 查询账号下资源包列表及余量 +注:该接口免费调用,方便您查询账号下的资源包列表和余量,但请您注意控制请求速率(QPS<=1) +| 网络协议 | https | +|---|---| +| 请求地址 | /account/costs | +| 请求方法 | GET | +| 请求格式 | application/json | +| 响应格式 | application/json | + +请求头 +| 字段 | 值 | 描述 | +|---|---|---| +| Content-Type | application/json | 数据交换格式 | +| Authorization | 鉴权信息,参考接口鉴权 | 鉴权信息,参考接口鉴权 | +请求路径参数 +| 字段 | 类型 | 必填 | 默认值 | 描述 | +|---|---|---|---|---| +| start_time | int | 是 | 无 | 查询的开始时间,Unix时间戳、单位ms | +| end_time | int | 是 | 无 | 查询的结束时间,Unix时间戳、单位ms | +| resource_pack_name | string | 否 | 无 | 资源包名称,用于精准指定查询某个资源包 | +请求体 +无 +响应体 + + +七、计费说明 +计费方式 +目前仅提供购买预付费资源包的形式 +目前资源包按照 “能力” 分为三类:视频生成资源包、图像生成资源包、虚拟试穿资源包。 + +积分扣减数量说明 +生成失败的任务不扣积分(任何原因导致的失败均不扣,包含因内容风控策略导致的失败) +图像生成资源包 +| 积分扣减数量说明 | | | +|---|---|---| +| 单张图片规格 | 资源包扣减 | 单价(原价) | +| 【可图Image-O1模型】文生图 | 从资源包总数里扣减8 | 0.2元 | +| 【可图Image-O1模型】图生图 | 从资源包总数里扣减8 | 0.2元 | +| 【可图Image-O1模型】图片编辑 | 从资源包总数里扣减8 | 0.2元 | +| 【可图Image-3O模型】 1K/2K | 从资源包总数里扣减8 | 0.2元 | +| 【可图Image-3O模型】 4K | 从资源包总数里扣减16 | 0.4元 | +| 【可图V1.0模型】文生图 | 从资源包总数里扣减1 | 0.025元 | +| 【可图V1.0模型】图生图 | 从资源包总数里扣减1 | 0.025元 | +| 【可图V1.5模型】文生图 | 从资源包总数里扣减4 | 0.1元 | +| 【可图V1.5模型】图生图 | 从资源包总数里扣减8 | 0.2元 | +| 【可图V2.0模型】文生图 | 从资源包总数里扣减4 | 0.1元 | +| 【可图V2.0模型】图生图 | 从资源包总数里扣减8 | 0.2元 | +| 【可图V2.0-new模型】图生图 | 从资源包总数里扣减8 | 0.2元 | +| 【可图V2.0模型】多图参考生图 | 从资源包总数里扣减16 | 0.4元 | +| 【可图V2.1模型】文生图 | 从资源包总数里扣减4 | 0.1元 | +| 【可图V2.1模型】图生图 | 从资源包总数里扣减8 | 0.2元 | +| 【可图V2.1模型】多图参考生图 | 从资源包总数里扣减16 | 0.4元 | +| 【可图V3.0模型】1K/2K | 从资源包总数里扣减8 | 0.2元 | +| 【图片编辑】扩图 | 从资源包总数里扣减8 | 0.2元 | +| 【智能补全主体图】按服务访问次数计费 | 从资源包总数里扣减20 | 0.5元 | + + +视频生成资源包 +| 积分扣减数量说明 | | | +|---|---|---| +| 单条视频规格 | 资源包扣减 | 单价(原价) | +| 【可灵Video-O1模型】标准(std)x 1s时长 x 无参考视频 | 从资源包总数里扣减0.6 | 0.6元 | +| 【可灵Video-O1模型】标准(std)x 1s时长 x 有参考视频 | 从资源包总数里扣减0.9 | 0.9元 | +| 【可灵Video-O1模型】高品质(pro)x 1s时长 x 无参考视频 | 从资源包总数里扣减0.8 | 0.8元 | +| 【可灵Video-O1模型】高品质(pro)x 1s时长 x 有参考视频 | 从资源包总数里扣减1.2 | 1.2元 | +| 【可灵V3-Omni模型】标准(std)x 1s时长 x 无参考视频 x 无声 | 从资源包总数里扣减0.6 | 0.6元 | +| 【可灵V3-Omni模型】标准(std)x 1s时长 x 无参考视频 x 有声 | 从资源包总数里扣减0.8 | 0.8元 | +| 【可灵V3-Omni模型】标准(std)x 1s时长 x 有参考视频 x 无声 | 从资源包总数里扣减0.9 | 0.9元 | +| 【可灵V3-Omni模型】高品质(pro)x 1s时长 x 无参考视频 x 无声 | 从资源包总数里扣减0.8 | 0.8元 | +| 【可灵V3-Omni模型】高品质(pro)x 1s时长 x 无参考视频 x 有声 | 从资源包总数里扣减1.0 | 1.0元 | +| 【可灵V3-Omni模型】高品质(pro)x 1s时长 x 有参考视频 x 无声 | 从资源包总数里扣减1.2 | 1.2元 | +| 【可灵V1模型】标准(std)x 5s时长 | 从资源包总数里扣减1 | 1元 | +| 【可灵V1模型】标准(std)x 10s时长 | 从资源包总数里扣减2 | 2元 | +| 【可灵V1模型】高品质(pro)x 5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【可灵V1模型】高品质(pro)x 10s时长 | 从资源包总数里扣减7 | 7元 | +| 【可灵V1.5模型】标准(std)x 5s时长 | 从资源包总数里扣减2 | 2元 | +| 【可灵V1.5模型】标准(std)x 10s时长 | 从资源包总数里扣减4 | 4元 | +| 【可灵V1.5模型】高品质(pro)x 5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【可灵V1.5模型】高品质(pro)x 10s时长 | 从资源包总数里扣减7 | 7元 | +| 【可灵V1.6模型】标准(std)x 5s时长 | 从资源包总数里扣减2 | 2元 | +| 【可灵V1.6模型】标准(std)x 10s时长 | 从资源包总数里扣减4 | 4元 | +| 【可灵V1.6模型】高品质(pro)x 5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【可灵V1.6模型】高品质(pro)x 10s时长 | 从资源包总数里扣减7 | 7元 | +| 【可灵V1.6多图参考生视频】标准(std)x 5s时长 | 从资源包总数里扣减2 | 2元 | +| 【可灵V1.6多图参考生视频】标准(std)x 10s时长 | 从资源包总数里扣减4 | 4元 | +| 【可灵V1.6多图参考生视频】高品质(pro)x 5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【可灵V1.6多图参考生视频】高品质(pro)x 10s时长 | 从资源包总数里扣减7 | 7元 | +| 【可灵V2.0大师版模型】x 5s时长 | 从资源包总数里扣减10 | 10元 | +| 【可灵V2.0大师版模型】x 10s时长 | 从资源包总数里扣减20 | 20元 | +| 【可灵V2.1模型】标准(std)x 5s时长 | 从资源包总数里扣减2 | 2元 | +| 【可灵V2.1模型】标准(std)x 10s时长 | 从资源包总数里扣减4 | 4元 | +| 【可灵V2.1模型】高品质(pro)x 5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【可灵V2.1模型】高品质(pro)x 10s时长 | 从资源包总数里扣减7 | 7元 | +| 【可灵V2.1大师版模型】x 5s时长 | 从资源包总数里扣减10 | 10元 | +| 【可灵V2.1大师版模型】x 10s时长 | 从资源包总数里扣减20 | 20元 | +| 【可灵V2.5 turbo模型】标准(std)x 5s时长 | 从资源包总数里扣减1.5 | 1.5元 | +| 【可灵V2.5 turbo模型】标准(std)x 10s时长 | 从资源包总数里扣减3 | 3元 | +| 【可灵V2.5 turbo模型】高品质(pro)x 5s时长 | 从资源包总数里扣减2.5 | 2.5元 | +| 【可灵V2.5 turbo模型】高品质(pro)x 10s时长 | 从资源包总数里扣减5 | 5元 | +| 【可灵V2.6模型】标准(std)x 5s时长 x 无声 x 未指定音色 | 从资源包总数里扣减1.5 | 1.5元 | +| 【可灵V2.6模型】标准(std)x 10s时长 x 无声 x 未指定音色 | 从资源包总数里扣减3 | 3元 | +| 【可灵V2.6模型】高品质(pro)x 5s时长 x 无声 x 未指定音色 | 从资源包总数里扣减2.5 | 2.5元 | +| 【可灵V2.6模型】高品质(pro)x 10s时长 x 无声 x 未指定音色 | 从资源包总数里扣减5 | 5元 | +| 【可灵V2.6模型】高品质(pro)x 5s时长 x 有声 x 未指定音色 | 从资源包总数里扣减5 | 5元 | +| 【可灵V2.6模型】高品质(pro)x 10s时长 x 有声 x 未指定音色 | 从资源包总数里扣减10 | 10元 | +| 【可灵V2.6模型】高品质(pro)x 5s时长 x 有声 x 有指定音色 | 从资源包总数里扣减6 | 6元 | +| 【可灵V2.6模型】高品质(pro)x 10s时长 x 有声 x 有指定音色 | 从资源包总数里扣减12 | 12元 | +| 【可灵V3.0模型】标准(std)x 1s时长 x 无声 | 从资源包总数里扣减0.6 | 0.6元 | +| 【可灵V3.0模型】标准(std)x 1s时长 x 有声 x 未指定音色 | 从资源包总数里扣减0.9 | 0.9元 | +| 【可灵V3.0模型】高品质(pro)x 1s时长 x 无声 | 从资源包总数里扣减0.8 | 0.8元 | +| 【可灵V3.0模型】高品质(pro)x 1s时长 x 有声 x 未指定音色 | 从资源包总数里扣减1.2 | 1.2元 | +| 【动作控制】可灵V2.6模型_标准(std)x 1s时长 | 从资源包总数里扣减0.5 | 0.5元 | +| 【动作控制】可灵V2.6模型_高品质(pro)x 1s时长 | 从资源包总数里扣减0.8 | 0.8元 | +| 【动作控制】可灵V3.0模型_标准(std)x 1s时长 | 从资源包总数里扣减0.9 | 0.9元 | +| 【动作控制】可灵V3.0模型_高品质(pro)x 1s时长 | 从资源包总数里扣减1.2 | 1.2元 | +| 【多模态视频编辑】可灵V1.6模型_标准(std)x 5s时长 | 从资源包总数里扣减3 | 3元 | +| 【多模态视频编辑】可灵V1.6模型_标准(std)x 10s时长 | 从资源包总数里扣减6 | 6元 | +| 【多模态视频编辑】可灵V1.6模型_高品质(pro)x 5s时长 | 从资源包总数里扣减5 | 5元 | +| 【多模态视频编辑】可灵V1.6模型_高品质(pro)x 10s时长 | 从资源包总数里扣减10 | 10元 | +| 【视频延长】可灵V1模型_标准(std) x 4~5s时长 | 从资源包总数里扣减1 | 1元 | +| 【视频延长】可灵V1模型_高品质(pro) x 4~5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【视频延长】可灵V1.5模型_标准(std) x 4~5s时长 | 从资源包总数里扣减2 | 2元 | +| 【视频延长】可灵V1.5模型_高品质(pro) x 4~5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【视频延长】可灵V1.6模型_标准(std) x 4~5s时长 | 从资源包总数里扣减2 | 2元 | +| 【视频延长】可灵V1.6模型_高品质(pro) x 4~5s时长 | 从资源包总数里扣减3.5 | 3.5元 | +| 【数字人】标准(std)x 按时长收费,以秒为单位,四舍五入取整 | 每秒从资源包总数里扣减0.4积分 | 0.4元 | +| 【数字人】高品质(pro)x 按时长收费,以秒为单位,四舍五入取整 | 每秒从资源包总数里扣减0.8积分 | 0.8元 | +| 【对口型】 与视频时长相关,不足5秒按5秒计算 | 每5秒从资源包总数里扣减0.5积分 | 0.5元 | +| 【特效模板】与模版相关,每个特效模板费用不同 | 详见:特效价目表 | 详见:特效价目表 | +| 【视频配音效】可灵音频模型 x 3~20s时长 | 从资源包总数里扣减0.25 | 0.25元 | +| 【文生音效】可灵音频模型 x 3~10s时长 | 从资源包总数里扣减0.25 | 0.25元 | +| 【人脸识别】按服务访问次数计费 | 每次从资源包总数里扣减0.05积分 | 0.05元 | +| 【语音合成】按服务访问次数计费 | 每次从资源包总数里扣减0.05积分 | 0.05元 | +| 【图像识别】按服务访问次数计费,一次访问可得图片中所有类型元素的识别结果 | 每次从资源包总数里扣减0.1积分 | 0.1元 | +| 【音色定制】按调用次数计费 | 每次从资源包总数里扣减0.05积分 | 0.05元 | + + +虚拟试穿资源包 +| 积分扣减数量说明 | | | +|---|---|---| +| 单张图片规格 | 资源包扣减 | 单价(原价) | +| 【可图-虚拟试穿V1模型】 | 从资源包总数里扣减1 | 0.5元 | +| 【可图-虚拟试穿V1.5模型】 | 从资源包总数里扣减1 | 0.5元 | diff --git a/docs/meijiaka-zhijian-final-plan.md b/docs/meijiaka-zhijian-final-plan.md new file mode 100644 index 0000000..cb5a059 --- /dev/null +++ b/docs/meijiaka-zhijian-final-plan.md @@ -0,0 +1,101 @@ +# 美家卡-智剪 (Meijiaka Smart Cut) 项目开发实施方案 + +基于您的最新反馈与确认,本项目将以《golden-purring-crown.md》(方案A)为主要交互蓝本进行落地,明确采用 **手动匹配分镜视频**、**完全本地化数据存储** 和 **沿用现有架构新建仓库** 的策略。 + +## 目标与改动背景 + +**项目背景**:衍生自现有的「美家卡智影」,新项目「美家卡智剪」侧重针对用户已有的视频素材,利用 AI 进行配音、并完成拼接与后期制作。 + +核心工作流程(6 步): +1. **脚本生成** (基于主题生成具有预估时长的分镜与旁白) +2. **视频剪辑 (新)** (用户手动为**每一个单分镜**导入对应长度的视频素材短片) +3. **音色配音 (新)** (用户本地维护音色特征,使用大模型 TTS 为所有分镜批量生成口播音频) +4. **字幕压制** (自动打轴并挂载 ASS 字幕,复用智影功能) +5. **封面制作** (根据首分镜首帧和文字生成封面,复用智影功能) +6. **视频合成** (所有片段首尾拼接成短视频,并将原有环境音替换/混音为合成音频,复用智影功能) + +--- + +## 核心设计决策 (User Confirmed) + +1. **交互模式**:不采用长视频自动切割算法。**必须采用单一分镜独立手动导入视频的交互**。 +2. **数据存储**:**纯本地文件系统**。所有业务数据(项目元数据、分镜配置、克隆好的本地音色记录等)全部保存在用户本地磁盘路径下,**不保存在云端数据库中**。 +3. **架构剥离**:通过拷贝文件系统级别进行剥离 (`rsync ai-meijiaka -> meijiaka-zj`),保留现有混合路由和本地缓存设计。 + +--- + +## Proposed Changes + +### 1. 架构剥离与仓库初始化 +在同级目录下快速搭建衍生仓库,移除不相干的缓存依赖。 + +#### [NEW] `meijiaka-zj/` (新建本地项目根目录) +- 配置应用标识词修正( `产品名: 美家卡智剪`, `Bundle Identifier: cn.meijiaka.ai-video-editor` 等)。 +- 修改并初始化 git 记录。 + +--- + +### 2. 后端 API (Python FastAPI) + +不再新建数据库表结构,将所有新 API 的核心转为与本地文件、大语言模型 API 之间的交互代理,由 AsyncEngine 发起。 + +#### [NEW] `python-api/app/scheduler/handlers/tts_handler.py` +创建用于处理批量语音生成的并发 Dispatcher。 + +#### [NEW] `python-api/app/services/voice_clone_service.py` & `tts_service.py` +包装调用 `KlingAIProvider`: +- 创建克隆音色的调用逻辑(由于无数据库,云端成功后的声纹特征及 `voice_id` 将通过 API 抛回前端并由 Tauri 存入本地 JSON 集合中)。 +- 提供语音合成和查询能力的端点。 + +#### [NEW] `python-api/app/api/v1/voice.py` +仅暴露无状态/代理转发类型的路由给前端:克隆状态查询、提交合成等。无 DB 依赖。 + +--- + +### 3. Rust 系统能力扩展 (src-tauri) + +由于采用本地存储,需要在 Rust 层扩展音频文件和声纹文件的安全存储指令。 + +#### [NEW] `tauri-app/src-tauri/src/storage/voice.rs` +新增声音本地缓存与描述管理,目录范例:`~/Documents/Meijiaka/voices/` (用于存储 voice meta.json 和相关的 reference audio)。 + +#### [NEW] `tauri-app/src-tauri/src/commands/voice.rs` +由 Tauri 提供存储IPC API给前端:读取本地音色列表、写入新克隆的音色等。 + +#### [MODIFY] `tauri-app/src-tauri/src/ffmpeg_cmd.rs` +**[重要机制更新]**: 实现目标音频覆盖处理,提供类似 `replace_audio_in_video` 的函数,依靠 `-c:v copy -c:a aac -shortest -map 0:v:0 -map 1:a:0` 剥离原声并在对应的短视频片段上压入新的 TTS 朗读声音。 + +--- + +### 4. 前端应用层 (tauri-app / React) + +调整原应用状态数据,创建本地数据绑定。 + +#### [MODIFY] `tauri-app/src/store/projectStore.ts` +扩展原 `SmartCutShot` 阶段参数,支持记录新增加的独立视频源地址 (`mediaPath`) 和单独段落的合成语音地址 (`audioPath`)。 + +#### [NEW] `tauri-app/src/store/voiceStore.ts` +与 `src-tauri` 通过 IPC 交互: +- 从本地加载用户维护在 `voices/` 下的所有自定义音色。 +- 处理前端的缓存与显示。 + +#### [NEW] `tauri-app/src/pages/VideoCreation/VideoEditing.tsx` (Step 2) +重定义分镜视频导入步骤: +- 为左侧每一个生成的分镜文案展示独立的 Upload/Select Box。 +- 用户可以点选或拖动,调用系统弹窗将 `mp4` 一对一绑定给自己心仪的旁白节点。 + +#### [NEW] `tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx` (Step 3) +批量克隆与TTS应用页面: +- 渲染本地和预定义的云端默认音库。 +- 前端批量发起所有含旁白分镜的异步合成任务,获取 URL 后调用 Rust 保留至项目对应的 `audio/` 子目录中。 + +--- + +## Verification Plan + +### Manual Verification (端到端走通测试) +- **环境**: 在新目录 `meijiaka-zj` 启动前后端服务。 +- **Step 1**: 使用纯业务旁白的模版生成分镜文案。 +- **Step 2**: 对列表中独立出现的 3 个分镜卡片,依次上传/拖入 3 个独立的 `.mp4` 文件以测试前端映射逻辑。 +- **Step 3(关键测试)**: 选择一个克隆音色发起全局合成。观察 `tts_slots` 运转状况。完毕后查验对应项目的物理存储路径内正确生成了 `.mp3` 音轨。 +- **Step 6**: 打包合成,测试 `ffmpeg_cmd.rs` 中音频替代逻辑是否执行无误,输出画面不掉帧、声音是合成口音的短片。 diff --git a/docs/meijiaka-zhijian-proposal.md b/docs/meijiaka-zhijian-proposal.md new file mode 100644 index 0000000..c9d7d4f --- /dev/null +++ b/docs/meijiaka-zhijian-proposal.md @@ -0,0 +1,833 @@ +# 美家卡智剪 — 产品技术方案 + +> 基于「美家卡智影」架构的 AI 辅助短视频剪辑产品方案 +> 版本: v2.0 | 日期: 2026-04-20 + +--- + +## 一、产品定位 + +| 维度 | 美家卡智影(现有) | 美家卡智剪(新项目) | +|------|-------------------|---------------------| +| **核心能力** | AI 数字人视频生成 | AI 音色克隆 + 语音合成 + 素材智能剪辑 | +| **视频来源** | KlingAI 生成数字人视频 | 用户导入长视频素材 | +| **声音来源** | KlingAI 预设/自定义音色 + 数字人 | 用户克隆音色 / 预设音色 + TTS | +| **目标场景** | 口播视频、营销视频从无到有 | 已有长素材快速剪辑成片、声音克隆配音 | +| **核心差异** | 「生成式」创作 | 「剪辑式」创作 + AI 声音 | + +### 一句话定义 +> **美家卡智剪** = 导入长视频 + AI 文案分镜 + 自动切割 + 音色克隆 + 语音合成 + 字幕压制 + 封面合成 + 视频导出 + +--- + +## 二、核心流程设计(6 步) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ │ +│ Step 1 → Step 2 → Step 3 → Step 4 → S5 → S6 │ +│ 脚本生成 → 视频剪辑 → 音色配音 → 字幕压制 → 封面 → 合成 │ +│ │ +│ ├─ AI文案 ├─ 导入长视频 ├─ 音色克隆 ├─ 自动打轴 │ +│ ├─ 粘贴文案 ├─ 自动切割 ├─ 预设音色 ├─ ASS字幕 │ +│ ├─ 智能分镜 │ (按分镜时长) ├─ 分镜TTS ├─ FFmpeg压制 │ +│ │ │ ├─ 试听/调整 │ +│ │ │ │ │ +│ [改造] [全新] [全新] [复用] [复用] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 各步骤详细说明 + +--- + +#### Step 1 — 脚本生成(Script Generation) + +**文案输入(3 种方式):** +1. **AI 生成**:输入主题/关键词,LLM 生成短视频文案 +2. **直接粘贴**:用户粘贴已准备好的文案,系统自动分镜 +3. **导入文件**:支持 `.txt` / `.docx` / `.srt` 导入 + +**智能分镜:** +- 按句子/段落自动拆分分镜 +- 每个分镜含:`voiceover`(旁白文案)、`duration`(预估时长) +- 支持拖拽调整分镜顺序、合并、拆分 +- 文案字数根据目标时长自动约束(15s≈40字 / 30s≈80字 / 60s≈160字) + +**输出:** +- `segments[]`:分镜列表,每个分镜含文案和预估时长 +- 此步骤与智影 Step 1 基本一致,Prompt 调整为生成纯旁白文案(不含场景描述) + +--- + +#### Step 2 — 视频剪辑(Video Editing) + +**核心逻辑:导入一个长视频,按分镜时长自动切割。** + +**流程:** +1. 用户导入一个长视频文件(`.mp4/.mov`) +2. 系统提取视频总时长 +3. 按分镜数量和预估时长自动计算切割点 +4. 调用 FFmpeg 将长视频切割为 N 个片段 +5. 每个片段自动绑定到对应分镜 + +**自动切割算法:** +``` +总视频时长 = T +分镜数 = N +分镜预估时长 = [d1, d2, ..., dN] +预估总时长 = D = d1 + d2 + ... + dN + +如果 D <= T: + 按比例分配: 每个分镜实际时长 = di * (T / D) + 切割点: cumsum([d1*T/D, d2*T/D, ...]) + +如果 D > T: + 提示用户: 文案预估总时长超过视频时长,建议缩短文案或导入更长视频 +``` + +**界面示意:** +``` +┌────────────────────────────────────────────┐ +│ 分镜列表 │ 素材导入 │ +│ ├─ 分镜1 (5s) │ ├─ 📁 点击导入 │ +│ ├─ 分镜2 (8s) │ │ 或拖拽视频 │ +│ ├─ 分镜3 (7s) │ │ │ +│ └─ 分镜4 (5s) │ │ 🎬 素材.mp4 │ +│ │ │ 时长: 25s │ +│ 预估总时长: 25s │ │ 分辨率: 1080p │ +│ │ └────────────────│ +│ [自动切割] │ │ +└────────────────────────────────────────────┘ + +切割结果预览: +┌────────────────────────────────────────────┐ +│ 分镜1 ←→ 🎬 [00:00 - 00:05] (5s) │ +│ 分镜2 ←→ 🎬 [00:05 - 00:13] (8s) │ +│ 分镜3 ←→ 🎬 [00:13 - 00:20] (7s) │ +│ 分镜4 ←→ 🎬 [00:20 - 00:25] (5s) │ +└────────────────────────────────────────────┘ +``` + +**技术实现:** +- 前端:文件选择 → 调用 Rust IPC `import_media` → 保存到项目 `media/` 目录 +- Rust:`split_video` 命令使用 FFmpeg `-ss` + `-t` 截取片段 +- 每个片段保存为 `shot_{index}.mp4`,路径写入 `segment.mediaPath` + +--- + +#### Step 3 — 音色配音(Voice & Dubbing) + +**音色管理:** +- **预设音色**:接入 KlingAI 官方预设音色(温柔女声、播报男声等) +- **我的音色**:用户克隆的音色列表 + - 克隆方式:录音(10-20 秒)或上传音频文件 + - 克隆状态:处理中 / 完成 / 失败 + - 支持预览、重命名、删除 + +**语音合成(TTS):** +- 为每个分镜独立选择音色 +- 支持统一设置(一键应用到全部分镜) +- 可调节语速(0.8x - 2.0x) +- 实时试听、重新生成 + +**批量合成:** +- 一键合成所有分镜音频 +- 后台 Async Engine 并行处理(受槽位限制) +- 实时进度显示 + +--- + +#### Step 4 — 字幕压制(Subtitle Burning) + +**基本复用智影现有逻辑,数据源变化:** +- 原:基于数字人视频的音频流进行自动打轴 +- 新:基于 TTS 合成的音频文件进行自动打轴 + +**流程:** +1. 提交 `subtitle` 任务(`mode: auto_align`) +2. 参数:`audioUrl`(TTS 音频)+ `audioText`(分镜文案) +3. 返回 `alignmentResult`(utterances 时间轴) +4. 用户选择字幕样式(颜色/字号/描边/位置) +5. 调用 Rust IPC `burn_subtitle` 压制 ASS 字幕到视频 + +**输出:** +- 每个分镜生成 `burnedVideoPath`(素材视频 + TTS 音频 + ASS 字幕) + +--- + +#### Step 5 — 封面制作(Cover Design) + +**完全复用智影现有逻辑:** +1. 提取第一个分镜视频的首帧作为背景 +2. 用户输入封面标题 +3. 选择字体样式(抖音美好体等) +4. 调用 Rust IPC `generate_cover_image` 合成封面 + +--- + +#### Step 6 — 视频合成(Video Composite) + +**完全复用智影现有逻辑:** +1. 收集所有分镜的 `burnedVideoPath` +2. 如有封面图,先转为 0.5s 封面视频 +3. 调用 Rust IPC `video_composite_synthesis` 拼接所有片段 +4. 输出最终成品到 `~/Documents/Meijiaka/products/` + +--- + +## 三、功能模块对比矩阵 + +| 模块 | 智影(现有) | 智剪(新) | 复用度 | +|------|-------------|-----------|--------| +| **脚本生成** | AI 生成脚本 | AI 生成文案 + 粘贴/导入 | 🔶 改造 | +| **视频生成** | KlingAI 数字人 | 素材导入 + **自动切割** | 🔴 新增 | +| **音色管理** | KlingAI Element 绑定音色 | 独立音色克隆 + 预设库 | 🔶 改造 | +| **语音合成** | 数字人自带口播 | TTS 独立合成音频 | 🔴 新增 | +| **字幕压制** | 自动打轴+FFmpeg | 完全复用 | 🟢 复用 | +| **封面制作** | 首帧+标题+FFmpeg | 完全复用 | 🟢 复用 | +| **视频合成** | FFmpeg concat | 完全复用 | 🟢 复用 | +| **本地存储** | meta.json + segments.json | 扩展字段 | 🔶 改造 | +| **任务调度** | 6 个 Handler | 新增 TTS Handler | 🔶 改造 | +| **用户认证** | JWT + 手机号 | 完全复用 | 🟢 复用 | +| **形象克隆** | Avatar 完整流程 | 简化为音色克隆 | 🔶 改造 | + +> 🟢 完全复用 | 🔶 需要改造 | 🔴 全新开发 + +--- + +## 四、前端架构方案 + +### 4.1 页面结构 + +``` +tauri-app/src/pages/ +├── VideoCreation/ +│ ├── index.tsx # 6步流程容器(复用,调整步骤名) +│ ├── ScriptCreation.tsx # Step 1: 脚本生成(复用改造) +│ ├── VideoEditing.tsx # Step 2: 视频剪辑(全新) +│ ├── VoiceDubbing.tsx # Step 3: 音色配音(全新) +│ ├── SubtitleBurning.tsx # Step 4: 字幕压制(复用) +│ ├── CoverDesign.tsx # Step 5: 封面制作(复用) +│ └── VideoComposite.tsx # Step 6: 视频合成(复用) +``` + +### 4.2 Store 设计 + +#### projectStore(改造) + +```typescript +interface SmartCutState { + // === Step 1: 脚本与分镜 === + segments: SmartCutShot[]; + topic?: string; + scriptType?: string; + + // === Step 3: 音色配音 === + defaultVoiceId?: string; // 默认音色 + + // === Step 5+6: 封面与合成 === + coverPath?: string; + coverConfig?: CoverConfig; + finalVideoPath?: string; + exportedAt?: string; + + // === 流程状态 === + currentStep: number; // 1-6 +} + +interface SmartCutShot { + id: string; + type: 'segment' | 'empty_shot'; + voiceover: string; // 旁白文案 + duration: number; // 预估/实际时长 + + // === Step 2: 视频剪辑后绑定 === + mediaPath?: string; // 切割后的视频片段路径 + mediaStartTime?: number; // 在原视频中的起始时间(秒) + mediaEndTime?: number; // 在原视频中的结束时间(秒) + + // === Step 3: 配音配置 === + ttsConfig?: TTSConfig; + audioPath?: string; // TTS 合成音频本地路径 + audioUrl?: string; // TTS 音频远程 URL + + // === Step 4: 字幕与后期 === + alignmentResult?: AlignmentResult; + burnedVideoPath?: string; + burnedAt?: string; +} + +interface TTSConfig { + voiceId: string; + voiceName: string; + speed: number; // 0.8 - 2.0 +} +``` + +#### voiceStore(新增) + +```typescript +interface VoiceState { + // 预设音色 + presetVoices: PresetVoice[]; + presetVoicesLoading: boolean; + + // 用户克隆音色 + clonedVoices: ClonedVoice[]; + clonedVoicesLoading: boolean; + + // 当前选中的默认音色 + selectedVoiceId?: string; +} + +interface PresetVoice { + voiceId: string; + voiceName: string; + previewUrl?: string; + provider: string; +} + +interface ClonedVoice { + id: string; // vc_xxx + name: string; + providerVoiceId: string; // KlingAI 返回的 voice_id + provider: string; + status: 'processing' | 'succeed' | 'failed'; + previewUrl?: string; + createdAt: string; +} +``` + +### 4.3 新增 Hooks + +| Hook | 职责 | +|------|------| +| `useVoiceClone.ts` | 音色克隆:提交克隆、轮询状态、管理列表 | +| `useTTSGeneration.ts` | TTS 批量合成:提交任务、轮询、更新 segment | +| `useMediaImport.ts` | 素材导入:文件选择、调用 Rust IPC | +| `useAutoSplit.ts` | 自动切割:计算切割点、调用 split_video、绑定分镜 | + +### 4.4 API 模块 + +``` +tauri-app/src/api/modules/ +├── voice.ts # 音色克隆 / 预设音色 / 查询 / 删除 +├── tts.ts # TTS 提交 / 查询 / 批量 +├── script.ts # 复用,文案生成 +├── caption.ts # 复用,字幕相关 +└── videoComposite.ts # 复用,视频合成 +``` + +--- + +## 五、后端架构方案 + +### 5.1 新增 API 路由 + +```python +# python-api/app/api/v1/voice.py +@router.post("/voice/clone") # 提交音色克隆任务 +@router.get("/voice/clones") # 查询用户克隆音色列表 +@router.get("/voice/clones/{id}") # 查询单个克隆任务 +@router.delete("/voice/clones/{id}") # 删除克隆音色 +@router.get("/voice/presets") # 查询预设音色列表 + +# python-api/app/api/v1/tts.py +@router.post("/tts") # 提交 TTS 任务 +@router.get("/tts/{job_id}") # 查询 TTS 任务状态 +@router.post("/tts/batch") # 批量提交 TTS 任务 +``` + +### 5.2 新增 Async Engine Handler + +新增 **`tts`** 任务类型: + +```python +# app/scheduler/handlers/tts_handler.py + +class TTSHandler(AsyncHandler): + """TTS 语音合成 Handler + + 为每个分镜的文案生成语音音频。 + """ + job_type = "tts" + slot_key = "kling:tts_slots" + max_slots = 10 + + async def handle(self, job: JobRecord) -> list[StateChange]: + """处理流程: + 1. 从 job.payload 提取 text, voice_id, voice_speed + 2. 调用 KlingAI TTS API 生成音频 + 3. 轮询任务完成 + 4. 下载音频文件到本地项目目录 + 5. (可选)上传七牛云持久化 + 6. 返回结果含 audio_path, audio_url, duration + """ +``` + +**Redis 配置:** +``` +槽位 Key: kling:tts_slots +槽位数: 10 +``` + +### 5.3 新增 Service 层 + +```python +# app/services/tts_service.py +class TTSService: + """TTS 语音合成服务""" + + async def generate_audio( + self, + text: str, + voice_id: str, + voice_speed: float = 1.0, + output_dir: str | None = None, + ) -> TTSResult: + """生成单条 TTS 音频""" + + async def batch_generate( + self, + items: list[TTSRequest], + user_id: str, + ) -> list[str]: + """批量提交 TTS 任务到 Async Engine""" + +# app/services/voice_clone_service.py +class VoiceCloneService: + """音色克隆服务""" + + async def create_clone( + self, + voice_name: str, + audio_url: str, # 七牛云音频URL + user_id: str, + ) -> VoiceCloneJob: + """提交音色克隆任务到 KlingAI""" + + async def sync_clone_status( + self, + job_id: str, + ) -> VoiceCloneStatus: + """同步查询克隆任务状态(轻量操作,不走Async Engine)""" + + async def list_clones(self, user_id: str) -> list[ClonedVoice]: + """查询用户所有克隆音色""" +``` + +### 5.4 新增数据库模型 + +```python +# app/models/voice_clone.py +class VoiceClone(Base): + """用户克隆音色元数据(云端备份)""" + __tablename__ = "voice_clones" + + id: Mapped[str] = mapped_column(String(32), primary_key=True) # vc_xxx + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) + name: Mapped[str] = mapped_column(String(100)) + provider: Mapped[str] = mapped_column(String(50), default="klingai") + provider_voice_id: Mapped[str] = mapped_column(String(100)) + status: Mapped[str] = mapped_column(String(20)) # processing/succeed/failed + preview_url: Mapped[str | None] = mapped_column(String(500)) + fail_reason: Mapped[str | None] = mapped_column(Text) + deleted_at: Mapped[datetime | None] + created_at: Mapped[datetime] + updated_at: Mapped[datetime] +``` + +> 注:智剪中不需要 Element(形象主体),只需要 Voice(音色),因此独立建表更简洁。 + +### 5.5 复用已有能力 + +| 已有能力 | 复用方式 | +|---------|---------| +| `KlingAIProvider.generate_tts()` | 直接调用,封装到 Service 层 | +| `KlingAIProvider.create_custom_voice()` | 直接调用,封装到 VoiceCloneService | +| `KlingAIProvider.list_preset_voices()` | 直接调用 | +| `VolcengineCaptionService` | 完全复用,传入 TTS 音频 URL | +| `SlotManager` + `JobRegistry` | 完全复用 | +| `TokenManager` + `JWTTokenStrategy` | 完全复用 | +| `qiniu_service.upload()` | 复用,支持 audio 类型 | +| 七牛云上传凭证 | 复用 | + +--- + +## 六、Rust 层改造方案 + +### 6.1 新增 IPC 命令 + +```rust +// commands/media.rs +#[tauri::command] +async fn import_media( + app: AppHandle, + project_id: String, + source_path: String, +) -> Result + +// commands/video_edit.rs +#[tauri::command] +async fn split_video( + app: AppHandle, + input_path: String, + segments: Vec, // [{start, end, output_name}] +) -> Result, String> // 返回切割后的文件路径列表 +``` + +### 6.2 新增 FFmpeg 命令封装 + +在 `ffmpeg_cmd.rs` 中新增: + +```rust +/// 按时间范围批量截取视频片段 +/// +/// 输入一个长视频,按多个时间范围切割为独立文件 +pub async fn split_video_segments( + app: &AppHandle, + input: &str, + segments: &[(f64, f64, &str)], // (start, end, output_path) +) -> Result, FFmpegError> + +/// 提取视频元信息(时长、分辨率、码率等) +pub async fn probe_media_info( + input: &str, +) -> Result +``` + +### 6.3 本地存储路径扩展 + +```rust +// storage/paths.rs + +/// 项目素材目录:~/Documents/Meijiaka/projects/{id}/media/ +pub fn get_project_media_dir(project_id: &str) -> PathBuf + +/// 项目音频目录:~/Documents/Meijiaka/projects/{id}/audio/ +pub fn get_project_audio_dir(project_id: &str) -> PathBuf + +/// 项目分镜视频目录:~/Documents/Meijiaka/projects/{id}/shots/ +pub fn get_project_shots_dir(project_id: &str) -> PathBuf +``` + +存储结构: +``` +~/Documents/Meijiaka/ +├── projects/{project_id}/ +│ ├── meta.json +│ ├── segments.json +│ ├── media/ # 导入的原始素材 +│ │ └── source.mp4 # 原始长视频 +│ ├── shots/ # 自动切割后的分镜视频 +│ │ ├── shot_001.mp4 +│ │ └── shot_002.mp4 +│ ├── audio/ # TTS 生成的音频 +│ │ ├── tts_001.mp3 +│ │ └── tts_002.mp3 +│ └── assets/ # 封面等成品资源 +│ └── cover_xxx.png +``` + +--- + +## 七、AI 能力集成 + +### 7.1 音色克隆 + +**Provider: KlingAI(已具备能力)** + +``` +API: POST /v1/general/custom-voices +参数: + - voice_name: 音色名称 + - voice_url: 音频文件URL(5-30秒,干净人声) + +限制: + - 音频时长: 5-30 秒 + - 格式: MP3 / WAV + - 要求: 单一人声、无杂音、无背景音乐 +``` + +**前端录音方案:** +- 使用 Web Audio API 录制麦克风音频 +- 实时波形可视化 +- 录制时长控制(10-20 秒最佳) +- 录制完成后上传至七牛云 → 后端提交克隆任务 + +**状态流转:** +``` +用户录音/上传 → 前端上传七牛云 → 后端调用 KlingAI 创建音色 + ↓ + [processing] ← 前端轮询 + ↓ + [succeed] → 保存到 DB → 加入"我的音色" + ↓ + [failed] → 提示用户重新录制 +``` + +### 7.2 语音合成(TTS) + +**Provider: KlingAI(已具备能力,需上层封装)** + +``` +API: POST /v1/audio/tts +参数: + - text: 要合成的文本(旁白文案) + - voice_id: 音色ID(预设或自定义) + - voice_language: zh / en + - voice_speed: 0.8 - 2.0(默认 1.0) + +返回: + - task_id: 任务ID + - 轮询 GET /v1/audio/tts/{task_id} 获取音频URL +``` + +**批量处理策略:** +- 每个分镜一个 TTS 任务 +- Async Engine 并行处理(最多 10 个并发) +- 前端显示总体进度(已完成 N / 总分镜数 M) + +### 7.3 文案生成 + +**复用现有 ScriptService**,但调整 Prompt: +- 原:生成「场景描述 + 旁白 + 时长」的营销脚本 +- 新:生成「旁白文案 + 预估时长」的短视频文案 +- 支持根据目标时长(15s / 30s / 60s)控制字数 + +--- + +## 八、独立新仓库初始化方案 + +### 8.1 仓库创建 + +```bash +# 在本地创建新仓库目录 +mkdir meijiaka-zj +cd meijiaka-zj +git init + +# 复制智影代码(排除依赖和构建产物) +rsync -av \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='.venv' \ + --exclude='__pycache__' \ + --exclude='.mypy_cache' \ + --exclude='.ruff_cache' \ + --exclude='.pytest_cache' \ + --exclude='dist' \ + --exclude='target' \ + --exclude='*.lock' \ + --exclude='.DS_Store' \ + ../ai-meijiaka/ . + +# 初始化提交 +git add -A +git commit -m "init: fork from meijiaka-zy" +``` + +### 8.2 品牌配置修改清单 + +| 文件 | 修改项 | +|------|--------| +| `tauri-app/src-tauri/tauri.conf.json` | `productName`: 美家卡智影 → 美家卡智剪;`identifier`: `cn.meijiaka.ai-video` → `cn.meijiaka.ai-video-editor`;`title`: 美家卡 智影 → 美家卡 智剪 | +| `tauri-app/package.json` | `name`: 可保持不变(内部包名) | +| `python-api/app/main.py` | FastAPI 文档标题、描述更新 | +| `AGENTS.md` | 全文替换「智影」→「智剪」,更新产品描述 | +| `README.md` | 更新为智剪的产品说明 | + +### 8.3 项目结构 + +``` +meijiaka-zj/ # 新仓库根目录 +├── python-api/ # FastAPI 后端(从智影复制后改造) +│ ├── app/ +│ │ ├── api/v1/ # 新增 voice.py, tts.py 路由 +│ │ ├── ai/providers/ # 复用 KlingAIProvider +│ │ ├── scheduler/handlers/ # 新增 tts_handler.py +│ │ ├── services/ # 新增 tts_service.py, voice_clone_service.py +│ │ ├── models/ # 新增 voice_clone.py +│ │ └── schemas/ # 新增 voice.py, tts.py +│ ├── config/ +│ ├── alembic/ +│ ├── pyproject.toml +│ └── ... +│ +├── tauri-app/ # Tauri 前端(从智影复制后改造) +│ ├── src/ +│ │ ├── pages/VideoCreation/ +│ │ │ ├── ScriptCreation.tsx # Step 1(改造) +│ │ │ ├── VideoEditing.tsx # Step 2(新增) +│ │ │ ├── VoiceDubbing.tsx # Step 3(新增) +│ │ │ ├── SubtitleBurning.tsx # Step 4(复用) +│ │ │ ├── CoverDesign.tsx # Step 5(复用) +│ │ │ └── VideoComposite.tsx # Step 6(复用) +│ │ ├── store/ +│ │ │ ├── projectStore.ts # 改造 +│ │ │ └── voiceStore.ts # 新增 +│ │ ├── api/modules/ +│ │ │ ├── voice.ts # 新增 +│ │ │ └── tts.ts # 新增 +│ │ └── hooks/ +│ │ ├── useVoiceClone.ts # 新增 +│ │ ├── useTTSGeneration.ts # 新增 +│ │ └── useAutoSplit.ts # 新增 +│ ├── src-tauri/src/ +│ │ ├── commands/media.rs # 新增 +│ │ ├── ffmpeg_cmd.rs # 新增函数 +│ │ └── storage/paths.rs # 新增路径 +│ └── ... +│ +├── docs/ # 文档 +│ └── meijiaka-zhijian-proposal.md +│ +└── scripts/ # 工具脚本 +``` + +--- + +## 九、实施路线图 + +### Phase 1: 基础架构(2 周) + +**目标**:搭建新项目骨架,打通基础能力 + +| 任务 | 说明 | +|------|------| +| ① 仓库初始化 | 复制智影代码,修改品牌配置,建立独立仓库 | +| ② 数据模型改造 | 新增 `voice_clones` 表,改造 `segments` Schema | +| ③ TTS API 封装 | 新增 `tts_service.py`、`voice.py` / `tts.py` 路由 | +| ④ 音色克隆 API | 新增 `voice_clone_service.py` | +| ⑤ 前端 Store 改造 | 改造 `projectStore`,新增 `voiceStore` | +| ⑥ 素材导入 IPC | 新增 `import_media`、`split_video` Rust 命令 | + +### Phase 2: 核心流程(2 周) + +**目标**:完成 6 步核心流程 MVP + +| 任务 | 说明 | +|------|------| +| ⑦ Step 1 脚本生成 | 复用现有逻辑,Prompt 调整为纯旁白文案 | +| ⑧ Step 2 视频剪辑 | 素材导入 UI + 自动切割逻辑 + 分镜绑定 | +| ⑨ Step 3 音色配音 | 音色克隆 UI + TTS 合成 UI + 批量任务 | +| ⑩ TTS Async Handler | 实现 `TTSHandler`,接入 Async Engine | +| ⑪ 字幕压制适配 | 基于 TTS 音频的自动打轴 + 字幕压制 | +| ⑫ 封面+合成 | 复用现有逻辑,验证端到端流程 | + +### Phase 3: 打磨优化(1 周) + +**目标**:提升用户体验,修复问题 + +| 任务 | 说明 | +|------|------| +| ⑬ 切割算法优化 | 智能检测场景切换点,避免在人物说话中间切割 | +| ⑭ 批量操作优化 | 统一音色、批量重新合成 | +| ⑮ 错误处理 | 视频格式不支持、TTS 失败、文案超长等异常 | +| ⑯ 性能优化 | 大视频导入、多任务并发 | +| ⑰ 测试验收 | 全流程测试,修复 bug | + +### 总工期预估:**5 周** + +``` +Week 1-2: Phase 1 — 基础架构 +Week 3-4: Phase 2 — 核心流程 MVP +Week 5: Phase 3 — 打磨优化 + 测试 +``` + +--- + +## 十、技术风险与应对 + +| 风险 | 影响 | 应对方案 | +|------|------|---------| +| KlingAI TTS 并发限制 | 批量合成慢 | Async Engine 槽位控制 + 前端进度管理 | +| KlingAI 音色克隆失败率高 | 用户体验差 | 前端引导用户录制规范音频(安静环境、清晰人声) | +| 文案总时长 > 视频时长 | 无法完整配音 | Step 2 导入时校验,超长则提示用户调整文案或换视频 | +| 自动切割点落在不自然位置 | 画面割裂 | V2 引入场景切换检测,在关键帧处切割 | +| 大视频文件导入卡顿 | 前端无响应 | Tauri 后端异步处理导入,前端仅显示进度 | +| 视频格式兼容性 | 某些格式无法处理 | FFmpeg 统一标准化转码,支持主流格式 | +| TTS 文本过长 | KlingAI 限制 | 分镜文案字数控制(建议单分镜 < 200 字) | + +--- + +## 十一、长期演进方向 + +| 版本 | 功能 | +|------|------| +| **V1.0**(MVP)| 长视频导入 + 自动切割 + 音色克隆 + TTS + 字幕 + 封面 + 合成 | +| **V1.5** | 智能切割(基于场景切换检测) | +| **V2.0** | 多轨道编辑(背景音乐、音效、转场) | +| **V2.5** | AI 视频摘要(长视频自动提取精彩片段) | +| **V3.0** | 多音色对话(支持多人配音、角色音色) | + +--- + +## 附录 + +### A. 关键术语对照 + +| 智影术语 | 智剪对应 | 说明 | +|---------|---------|------| +| `elementId` | `voiceId` | 从数字人形象ID变为音色ID | +| `videoUrl` | `mediaPath` | 从AI生成视频变为切割后的素材片段 | +| `Avatar` | `VoiceClone` | 从形象克隆简化为音色克隆 | +| `humanId` | — | 移除,不再需要 | +| `scene` | — | 可选保留,用于V2智能匹配 | + +### B. 需要改造的文件清单 + +**后端(python-api):** +``` +新增: + app/api/v1/voice.py + app/api/v1/tts.py + app/services/tts_service.py + app/services/voice_clone_service.py + app/scheduler/handlers/tts_handler.py + app/models/voice_clone.py + app/schemas/voice.py + app/schemas/tts.py + +改造: + app/scheduler/main.py # 注册 TTSHandler + app/api/v1/router.py # 添加 voice/tts 路由 + app/schemas/segment.py # 扩展 Segment Schema + app/ai/prompts/script/*.txt # 调整 Prompt 为纯旁白文案 +``` + +**前端(tauri-app):** +``` +新增: + src/pages/VideoCreation/VideoEditing.tsx + src/pages/VideoCreation/VoiceDubbing.tsx + src/store/voiceStore.ts + src/api/modules/voice.ts + src/api/modules/tts.ts + src/hooks/useVoiceClone.ts + src/hooks/useTTSGeneration.ts + src/hooks/useAutoSplit.ts + +改造: + src/pages/VideoCreation/index.tsx # 调整为6步 + src/pages/VideoCreation/ScriptCreation.tsx # 移除场景描述字段 + src/store/projectStore.ts # 扩展数据模型 + src/api/types.ts # 更新类型定义 +``` + +**Rust(src-tauri):** +``` +新增: + src/commands/media.rs + src/ffmpeg_cmd.rs 中的 split_video_segments / probe_media_info + src/storage/paths.rs 中的 media/audio/shots 路径 + +改造: + src/lib.rs # 注册新命令 +``` + +--- + +*本方案基于「美家卡智影」现有架构设计,最大化复用已有能力,降低开发成本与风险。* diff --git a/docs/migrate-avatars-to-local.md b/docs/migrate-avatars-to-local.md new file mode 100644 index 0000000..a4923eb --- /dev/null +++ b/docs/migrate-avatars-to-local.md @@ -0,0 +1,243 @@ +# 迁移方案:废弃云端 `mjk_avatars` 表,数字人元数据全量迁移到本地存储 + +> **状态:方案已调整(2026-04-17)** — 原方案中提到的 Celery 架构已完全移除,形象克隆现由 `app/scheduler/handlers/avatar_handler.py`(Async Engine Scheduler)统一调度。云端仍保留 `avatars` 表作为形象克隆的持久化记录。 + +## 方案目标 + +| 目标 | 说明 | +|------|------| +| ✅ 贯彻设计理念 | 真正做到**轻量云 + 全本地业务数据**,云端只记日志不存业务数据 | +| ✅ 统一接口日志 | 所有接口请求统一记录到 `mjk_interface_request_logs`,按接口统计积分消耗 | +| ✅ 简化后端代码 | 删除大量 CRUD、状态管理、定时任务代码,后端更干净 | +| ✅ 用户掌控数据 | 所有数字人元数据存在用户本地,云端只记克隆请求的消耗积分 | + +--- + +## 存储结构变化 + +### 变化前(现状) +``` +云端 PostgreSQL: mjk_avatars + └─ 存储所有数字人元数据 (name/voice_id/element_id/status 等) +前端本地: + └─ 只做缓存,从云端同步 +``` + +### 变化后(目标) +``` +云端 PostgreSQL: + ├─ mjk_interface_request_logs ← 只记:avatar_clone 请求 + 消耗积分 + 状态 + └─ mjk_avatars ← 废弃,不再写入新数据(存量可保留可删除) +用户本地磁盘: + └─ ~/Documents/Meijiaka/avatars/{avatar_id}/ + ├─ meta.json ← 完整数字人元数据(JSON) + └─ source.mp4 ← 原始上传视频 +``` + +--- + +## 本地存储结构定义 + +### 目录结构 +``` +~/Documents/Meijiaka/ +└── avatars/ + └── {avatar_id}/ # avatar_id = avt_{16位随机hex} + ├── meta.json # 元数据(JSON 格式) + └── source.mp4 # 原始上传视频 +``` + +### `meta.json` 结构 +```json +{ + "id": "avt_xxxxxxxxxxxxxxxx", + "name": "我的数字人", + "voiceId": "klingai-voice-id-string", + "elementId": 12345678, + "voiceTaskId": "kling-task-id-string", + "elementTaskId": "kling-task-id-string", + "videoUrl": "https://domain.com/path/to/source.mp4", + "trialUrl": "https://domain.com/path/to/trial.wav", + "status": "succeed", + "failReason": null, + "createdAt": "2026-04-16T10:00:00.000Z", + "updatedAt": "2026-04-16T10:05:00.000Z" +} +``` + +--- + +## 代码改动清单 + +### 后端 Python + +| 操作 | 文件 | 改动说明 | +|------|------|----------| +| 🆕 新增 | `app/models/interface_request_logs.py` | SQLAlchemy 模型 `InterfaceRequestLogs` | +| 🆕 新增 | `app/crud/interface_request_logs.py` | CRUD:create / update | +| ✏️ 修改 | `app/models/__init__.py` | 删除 `Avatar` 导入,新增 `InterfaceRequestLogs` | +| ✏️ 修改 | `app/api/v1/avatar.py` | 完全重写
• 保留:`POST /clone` / `GET /tasks/{id}` / `GET /clone/stream` / `POST /tasks/{id}/retry` / `DELETE /{id}`
• 删除:`GET /library` / `PATCH /{id}` / `/health` | +| ✏️ 修改 | `app/scheduler/handlers/avatar_handler.py` | 精简:删除所有对 `mjk_avatars` 读写,只记接口日志,进度放 Redis Registry | +| ❌ 删除 | `app/models/avatar.py` | 模型废弃,删除 | +| ❌ 删除 | `app/crud/avatar.py` | CRUD 废弃,删除 | +| ❌ 删除 | `app/tasks/avatar_clone.py` | 逻辑已合并到 avatar_handler,删除 | + +### Rust Tauri(`tauri-app/src-tauri/src/persistence.rs`) + +新增以下 IPC 命令: + +```rust +/// 列出所有本地数字人(按创建时间倒序) +#[tauri::command] +pub fn list_avatars(app: AppHandle) -> Result, String>; + +/// 保存数字人元数据 +#[tauri::command] +pub fn save_avatar(app: AppHandle, avatar_id: String, meta: AvatarMeta) -> Result<(), String>; + +/// 获取单个数字人元数据 +#[tauri::command] +pub fn get_avatar(app: AppHandle, avatar_id: String) -> Result, String>; + +/// 删除数字人(删除整个本地目录) +#[tauri::command] +pub fn delete_avatar(app: AppHandle, avatar_id: String) -> Result<(), String>; + +/// 更新数字人名称 +#[tauri::command] +pub fn update_avatar_name(app: AppHandle, avatar_id: String, name: String) -> Result<(), String>; +``` + +在 `lib.rs` 注册新命令。 + +### 前端 TypeScript + +| 模块 | 改动 | +|------|------| +| **Avatar 列表** | 原:`GET /avatar/library` 从后端获取 → 现在:调用 Tauri IPC 从本地读取 | +| **创建克隆** | 流程变化:
1. 前端生成 `avatar_id`
2. `POST /avatar/clone` → 获取 `task_id`
3. 前端创建本地目录 + 写入初始 `meta.json` (`status=pending`)
4. SSE 监听进度
5. 完成后 → 前端把 `voice_id`/`element_id` 写入本地 `meta.json`
6. 完成 | +| **删除 Avatar** | 流程变化:
1. 前端调用 `DELETE /avatar/{avatar_id}`(后端负责删除 Kling 远程资源)
2. 后端记删除日志到接口日志
3. 前端调用 IPC 删除本地目录 | +| **重命名 Avatar** | 原:调用后端 PATCH → 现在:前端直接修改本地 `meta.json`,无需请求后端 | +| **选择数字人生成视频** | 用法不变:从本地读取 `voice_id`/`element_id` → 传给后端视频生成接口 | + +--- + +## 工作流对比 + +### 改动前(当前) +``` +用户提交克隆 + → POST /clone → 后端写 mjk_avatars (status=pending) → 派发任务 + → Async Engine Scheduler (avatar_handler) 每一步都更新 `avatars` 表 + → 前端 SSE 轮询读 `avatars` 表拿进度 + → 完成后 Scheduler 更新 status=succeed 写入 voice_id/element_id + → 前端从 `avatars` 表读结果 → 缓存到本地 + → 列表从 `avatars` 表读取 +``` + +### 改动后(目标) +``` +用户提交克隆 + → 前端生成 avatar_id → 创建本地 meta.json (status=pending) + → POST /clone → 后端: + 1. 在 mjk_interface_request_logs 插入记录 + interface_type=avatar_clone, status=pending, started_at=now, cost_credits=X + 2. 注册到 Async Engine Scheduler (Redis Registry) + 3. 返回 {task_id, avatar_id} + → Async Engine Scheduler (avatar_handler) 执行: + 1. 调用 Kling 创建音色 → 轮询 → 获取 voice_id + 2. 调用 Kling 创建主体 → 轮询 → 获取 element_id + 3. 更新 Redis Registry 状态为 completed,写入结果 + 4. 更新接口日志: status=success/failed, finished_at=now + → 前端 SSE 从 TaskCache 获取结果 + → 完成后前端将 voice_id/element_id 写入本地 meta.json + → 列表展示直接从本地读取,不请求后端 +``` + +--- + +## 接口日志记录规则 + +`mjk_interface_request_logs` 对 `avatar_clone` 的记录: + +| 时机 | 操作 | 字段值 | +|------|------|--------| +| 刚收到请求 | 插入新记录 | `interface_type=avatar_clone`, `status=pending`, `started_at=NOW`, `cost_credits` = 克隆一次所需积分 | +| 任务完成成功 | 更新记录 | `status=success`, `finished_at=NOW` | +| 任务失败 | 更新记录 | `status=failed`, `finished_at=NOW`, `error_message=错误原因` | + +> 积分在请求创建时即扣除,因为无论成功失败,KlingAI 开始处理后会计费。 + +--- + +## 存量数据迁移策略 + +### 渐进迁移(对用户友好) + +1. **保留云端表**:`mjk_avatars` 保留不删除,存量数据继续存在 +2. **前端自动迁移**:用户首次打开形象库时: + - 前端检查:如果后端有数据但本地没有 → 提示用户"将云端数字人同步到本地" + - 用户确认后,前端逐个拉取数据写入本地 + - 同步完成后,后续只使用本地数据 +3. **下线旧表**:稳定运行一段时间后,可在维护窗口物理删除 `mjk_avatars` 表 + +### 回滚方案 +- 迁移过程中如果出问题,随时切回原逻辑(表保留,代码只需恢复删除部分) + +--- + +## 优缺点总结 + +| 优点 | 说明 | +|------|------| +| ✅ 完全符合需求 | 云端只存接口请求记录和消耗积分,不存用户业务数据 | +| ✅ 云端存储成本极低 | 只有接口日志,每条几KB,用户增长成本可控 | +| ✅ 后端代码大幅简化 | 删除了整个 Avatar CRUD、状态机管理、定时任务恢复,代码减少约 300 行 | +| ✅ 用户完全掌控数据 | 所有数字人元数据存储在用户本地磁盘 | +| ✅ 形象库展示更快 | 本地读取文件比查询数据库快很多 | +| ✅ 兼容存量数据 | 渐进迁移,可回滚 | + +| 缺点 | 说明 | 应对 | +|------|------|------| +| 用户换电脑需要迁移 | 用户需要自行迁移数据,或重新克隆 | 后续可增加导出/导入功能解决 | +| 本地硬盘损坏数据丢失 | 这是"全本地"设计的必然结果 | 符合项目初始"轻量云+全本地"设计理念,用户自担数据安全 | + +--- + +## 执行步骤(按顺序) + +1. **数据库** + - 生成 Alembic 迁移:所有表重命名加 `mjk_` 前缀 + 新建 `mjk_interface_request_logs` + - 修改所有 Python 模型中的 `__tablename__` + +2. **后端代码** + - 新建 `interface_request_logs` 模型和 CRUD + - 重写 `app/api/v1/avatar.py` + - 精简 `app/tasks/avatar_tasks.py` + - 删除废弃文件 + +3. **Rust Tauri** + - 在 `persistence.rs` 新增 avatar 相关 IPC 命令 + - 在 `lib.rs` 注册命令 + +4. **前端代码** + - 修改形象库:从本地读取 + - 修改创建流程:完成后写入本地 + - 修改删除流程:删除云端 Kling 资源后删除本地 + - 修改重命名:直接本地修改 + +5. **测试验证** + - 创建克隆 → 检查本地文件生成 → 检查接口日志写入 + - 列表展示 → 删除 → 重命名 全流程测试 + +--- + +## 相关文档 + +- [数据库设计规范](./database-design.md) - 完整的数据库命名规范和表结构 +- [视频生成流程](./video-generation-flow.md) - 完整视频生成流程说明 + +--- + +*版本:v1.0* +*创建日期:2026-04-16* diff --git a/docs/qiniu-kodo-python-sdk-guide.md b/docs/qiniu-kodo-python-sdk-guide.md new file mode 100644 index 0000000..bccfd23 --- /dev/null +++ b/docs/qiniu-kodo-python-sdk-guide.md @@ -0,0 +1,739 @@ +# 七牛云对象存储 (Kodo) Python SDK 开发规范 + +## 概述 + +本文档规范美家卡智影项目中使用七牛云对象存储 (Kodo) Python SDK 的开发标准,涵盖文件上传、下载、管理和 CDN 操作等核心功能。 + +**SDK 版本**: v5.0.0+ +**Python 版本**: 3.8+ (兼容 2.7 和 3.3+) +**官方文档**: https://developer.qiniu.com/kodo/1242/python + +--- + +## 1. 安装与初始化 + +### 1.1 安装 SDK + +```bash +pip install qiniu +``` + +### 1.2 初始化配置 + +```python +from qiniu import Auth + +# 从环境变量读取密钥(推荐) +import os +access_key = os.getenv('QINIU_ACCESS_KEY') +secret_key = os.getenv('QINIU_SECRET_KEY') + +# 构建鉴权对象 +q = Auth(access_key, secret_key) +``` + +**环境变量配置** (`.env` 文件): +```bash +QINIU_ACCESS_KEY=your-access-key +QINIU_SECRET_KEY=your-secret-key +QINIU_BUCKET_NAME=your-bucket-name +QINIU_BUCKET_DOMAIN=your-domain.com +``` + +--- + +## 2. 文件上传 + +### 2.1 上传方式选择 + +| 场景 | 推荐方式 | 说明 | +|------|----------|------| +| 小文件 (< 100MB) | 表单上传 (put_file) | 简单快速,一次请求完成 | +| 大文件 (> 100MB) | 分片上传 v2 (put_file_v2) | 支持断点续传,适应弱网环境 | +| 网络不稳定 | 分片上传 v2 | 自动重试,更可靠 | + +### 2.2 服务端生成上传 Token + +```python +from qiniu import Auth + +def generate_upload_token( + bucket_name: str, + key: str = None, + expires: int = 3600, + policy: dict = None +) -> str: + """ + 生成上传凭证 + + Args: + bucket_name: 存储空间名称 + key: 指定文件名(可选) + expires: Token 有效期(秒),默认 3600 + policy: 上传策略配置(可选) + + Returns: + 上传 Token 字符串 + """ + q = Auth(access_key, secret_key) + + # 自定义上传策略(可选) + if policy is None: + policy = {} + + token = q.upload_token(bucket_name, key, expires, policy) + return token +``` + +### 2.3 客户端直传(推荐) + +**服务端生成 Token,客户端直传到七牛云**: + +```python +# 服务端 API +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/qiniu", tags=["Qiniu"]) + +class UploadTokenRequest(BaseModel): + key: str # 文件名 + expires: int = 3600 # Token 有效期 + +class UploadTokenResponse(BaseModel): + token: str + key: str + upload_url: str = "https://upload.qiniup.com" + +@router.post("/upload-token", response_model=UploadTokenResponse) +async def get_upload_token(request: UploadTokenRequest): + """获取上传凭证,客户端直传""" + token = generate_upload_token( + bucket_name=os.getenv('QINIU_BUCKET_NAME'), + key=request.key, + expires=request.expires + ) + return UploadTokenResponse(token=token, key=request.key) +``` + +### 2.4 服务端上传文件(保留场景) + +```python +from qiniu import Auth, put_file_v2, etag +import qiniu.config + +def upload_file( + local_file_path: str, + key: str, + bucket_name: str = None +) -> dict: + """ + 服务端上传文件到七牛云 + + Args: + local_file_path: 本地文件路径 + key: 存储的文件名(如 "audios/voice.mp3") + bucket_name: 存储空间名称 + + Returns: + {"key": str, "hash": str, "url": str} + """ + bucket_name = bucket_name or os.getenv('QINIU_BUCKET_NAME') + + # 生成上传 Token + token = q.upload_token(bucket_name, key, 3600) + + # 使用分片上传 v2(推荐) + ret, info = put_file_v2( + up_token=token, + key=key, + file_path=local_file_path, + version='v2' # 指定分片上传 v2 版本 + ) + + if ret is None: + raise Exception(f"上传失败: {info}") + + # 验证文件完整性 + assert ret['key'] == key + assert ret['hash'] == etag(local_file_path) + + # 构建访问 URL + domain = os.getenv('QINIU_BUCKET_DOMAIN') + url = f"https://{domain}/{key}" + + return { + "key": ret['key'], + "hash": ret['hash'], + "url": url + } +``` + +### 2.5 上传策略 (PutPolicy) + +常用策略配置: + +```python +# 1. 限制文件大小 (10MB ~ 100MB) +policy = { + "fsizeMin": 1024 * 1024 * 10, # 最小 10MB + "fsizeLimit": 1024 * 1024 * 100, # 最大 100MB + "mimeLimit": "audio/*;video/*" # 限制文件类型 +} + +# 2. 上传后回调业务服务器 +policy = { + "callbackUrl": "https://your-api.com/callback", + "callbackBody": "key=$(key)&hash=$(etag)&fname=$(fname)&fsize=$(fsize)", + "callbackBodyType": "application/x-www-form-urlencoded" +} + +# 3. 上传后转码(持久化处理) +import base64 +fops = 'avthumb/mp4/s/640x360/vb/1.25m' +saveas_key = base64.urlsafe_b64encode(f'{bucket_name}:output.mp4'.encode()).decode() + +policy = { + "persistentOps": f"{fops}|saveas/{saveas_key}", + "persistentPipeline": "transcoding", # 队列名称 + "persistentNotifyUrl": "https://your-api.com/pfop/callback" +} +``` + +--- + +## 3. 文件下载 + +### 3.1 公有空间下载 + +公有空间文件可直接访问: + +```python +def get_public_url(key: str, domain: str = None) -> str: + """获取公有空间文件 URL""" + domain = domain or os.getenv('QINIU_BUCKET_DOMAIN') + return f"https://{domain}/{key}" +``` + +### 3.2 私有空间下载(临时 URL) + +```python +import requests + +def get_private_url(key: str, expires: int = 3600) -> str: + """ + 生成私有空间文件的临时下载 URL + + Args: + key: 文件 Key + expires: 链接有效期(秒) + + Returns: + 带签名的临时 URL + """ + domain = os.getenv('QINIU_BUCKET_DOMAIN') + base_url = f"https://{domain}/{key}" + + # 生成私有下载链接 + private_url = q.private_download_url(base_url, expires=expires) + return private_url + +# 使用示例 +def download_file(key: str, local_path: str): + """下载私有空间文件到本地""" + private_url = get_private_url(key, expires=3600) + + response = requests.get(private_url) + if response.status_code == 200: + with open(local_path, 'wb') as f: + f.write(response.content) + return True + return False +``` + +--- + +## 4. 文件管理 (BucketManager) + +### 4.1 初始化管理器 + +```python +from qiniu import Auth, BucketManager + +q = Auth(access_key, secret_key) +bucket = BucketManager(q) +``` + +### 4.2 获取文件信息 + +```python +def get_file_info(bucket_name: str, key: str) -> dict: + """ + 获取文件元信息 + + Returns: + { + "fsize": 文件大小(字节), + "hash": 文件哈希, + "mimeType": MIME类型, + "putTime": 上传时间(100纳秒时间戳), + "type": 存储类型(0=标准,1=低频,2=归档,3=深度归档) + } + """ + ret, info = bucket.stat(bucket_name, key) + if ret is None: + raise Exception(f"获取文件信息失败: {info}") + return ret +``` + +### 4.3 列举文件列表 + +```python +from typing import List, Optional + +def list_files( + bucket_name: str, + prefix: str = None, # 前缀筛选 + limit: int = 100, # 每页数量 + marker: str = None # 分页标记 +) -> dict: + """ + 列举空间文件列表 + + Returns: + { + "items": [{"key": ..., "fsize": ..., ...}], + "marker": "分页标记", + "commonPrefixes": ["公共前缀列表"] + } + """ + ret, eof, info = bucket.list( + bucket_name, + prefix=prefix, + marker=marker, + limit=limit, + delimiter=None # 不指定分隔符 + ) + + return { + "items": ret.get('items', []), + "marker": ret.get('marker'), + "eof": eof # 是否已列举完 + } + +# 遍历所有文件 +def list_all_files(bucket_name: str, prefix: str = None) -> List[dict]: + """遍历获取所有文件""" + files = [] + marker = None + + while True: + result = list_files(bucket_name, prefix, limit=1000, marker=marker) + files.extend(result['items']) + + if result['eof'] or not result['marker']: + break + marker = result['marker'] + + return files +``` + +### 4.4 删除文件 + +```python +def delete_file(bucket_name: str, key: str) -> bool: + """删除单个文件""" + ret, info = bucket.delete(bucket_name, key) + return ret == {} + +def delete_files_batch(bucket_name: str, keys: List[str]) -> dict: + """批量删除文件""" + from qiniu import build_batch_delete + + ops = build_batch_delete(bucket_name, keys) + ret, info = bucket.batch(ops) + return ret +``` + +### 4.5 复制和移动文件 + +```python +def copy_file( + src_bucket: str, + src_key: str, + dest_bucket: str, + dest_key: str, + force: bool = True +) -> bool: + """复制文件""" + ret, info = bucket.copy( + src_bucket, src_key, + dest_bucket, dest_key, + force=force # 强制覆盖 + ) + return ret is not None + +def move_file( + src_bucket: str, + src_key: str, + dest_bucket: str, + dest_key: str, + force: bool = True +) -> bool: + """移动/重命名文件""" + ret, info = bucket.move( + src_bucket, src_key, + dest_bucket, dest_key, + force=force + ) + return ret is not None +``` + +### 4.6 修改文件元信息 + +```python +def change_mime(bucket_name: str, key: str, mime_type: str): + """修改文件 MIME 类型""" + ret, info = bucket.change_mime(bucket_name, key, mime_type) + return ret is not None + +def change_type(bucket_name: str, key: str, file_type: int): + """ + 修改文件存储类型 + + file_type: + 0 = 标准存储 + 1 = 低频存储 + 2 = 归档存储 + 3 = 深度归档存储 + """ + ret, info = bucket.change_type(bucket_name, key, file_type) + return ret is not None +``` + +### 4.7 批量操作 + +```python +from qiniu import ( + build_batch_stat, + build_batch_copy, + build_batch_move, + build_batch_rename, + build_batch_delete +) + +def batch_stat(bucket_name: str, keys: List[str]) -> List[dict]: + """批量查询文件信息""" + ops = build_batch_stat(bucket_name, keys) + ret, info = bucket.batch(ops) + return ret + +def batch_rename( + bucket_name: str, + key_map: dict, # {"old_key": "new_key", ...} + force: bool = True +): + """批量重命名""" + ops = build_batch_rename(bucket_name, key_map, force=force) + ret, info = bucket.batch(ops) + return ret + +def batch_copy( + src_bucket: str, + key_map: dict, # {"src_key": "dest_key", ...} + dest_bucket: str = None, + force: bool = True +): + """批量复制""" + dest_bucket = dest_bucket or src_bucket + ops = build_batch_copy(src_bucket, key_map, dest_bucket, force=force) + ret, info = bucket.batch(ops) + return ret +``` + +### 4.8 抓取网络资源 + +```python +def fetch_remote_file( + remote_url: str, + key: str, + bucket_name: str = None +) -> dict: + """ + 抓取远程文件到七牛云 + + Args: + remote_url: 远程文件 URL + key: 保存的文件名 + bucket_name: 目标空间 + + Returns: + {"key": ..., "hash": ..., "fsize": ...} + """ + bucket_name = bucket_name or os.getenv('QINIU_BUCKET_NAME') + ret, info = bucket.fetch(remote_url, bucket_name, key) + return ret +``` + +--- + +## 5. CDN 操作 + +### 5.1 初始化 CDN Manager + +```python +from qiniu import CdnManager + +cdn_manager = CdnManager(q) +``` + +### 5.2 刷新 CDN 缓存 + +```python +def refresh_urls(urls: List[str]) -> dict: + """刷新指定 URL 的 CDN 缓存""" + ret, info = cdn_manager.refresh_urls(urls) + return ret + +def refresh_dirs(dirs: List[str]) -> dict: + """刷新整个目录的 CDN 缓存""" + ret, info = cdn_manager.refresh_dirs(dirs) + return ret +``` + +### 5.3 预取资源 + +```python +def prefetch_urls(urls: List[str]) -> dict: + """预取资源到 CDN 节点""" + ret, info = cdn_manager.prefetch_urls(urls) + return ret +``` + +### 5.4 获取 CDN 日志 + +```python +def get_cdn_log_list(domains: List[str], log_date: str) -> List[dict]: + """ + 获取 CDN 日志下载链接 + + Args: + domains: 域名列表 + log_date: 日期 (YYYY-MM-DD) + + Returns: + [{"name": ..., "url": ..., "size": ..., "mtime": ...}] + """ + ret, info = cdn_manager.get_log_list_data(domains, log_date) + return ret.get('data', []) +``` + +--- + +## 6. 项目集成方案 + +### 6.1 服务端封装模块 + +```python +# app/services/qiniu_service.py +""" +七牛云对象存储服务封装 +""" + +import os +from typing import List, Optional +from qiniu import Auth, BucketManager, CdnManager, put_file_v2, etag + +class QiniuService: + """七牛云服务封装""" + + def __init__(self): + access_key = os.getenv('QINIU_ACCESS_KEY') + secret_key = os.getenv('QINIU_SECRET_KEY') + self.bucket_name = os.getenv('QINIU_BUCKET_NAME') + self.domain = os.getenv('QINIU_BUCKET_DOMAIN') + + self.auth = Auth(access_key, secret_key) + self.bucket = BucketManager(self.auth) + self.cdn = CdnManager(self.auth) + + def get_upload_token(self, key: str, expires: int = 3600, policy: dict = None) -> str: + """生成上传 Token""" + return self.auth.upload_token(self.bucket_name, key, expires, policy) + + def get_file_url(self, key: str, private: bool = False, expires: int = 3600) -> str: + """获取文件访问 URL""" + base_url = f"https://{self.domain}/{key}" + if private: + return self.auth.private_download_url(base_url, expires) + return base_url + + def upload_file(self, local_path: str, key: str) -> dict: + """服务端上传文件""" + token = self.get_upload_token(key) + ret, info = put_file_v2(token, key, local_path, version='v2') + + if ret is None: + raise Exception(f"上传失败: {info}") + + return { + "key": ret['key'], + "hash": ret['hash'], + "url": self.get_file_url(key) + } + + def delete_file(self, key: str) -> bool: + """删除文件""" + ret, info = self.bucket.delete(self.bucket_name, key) + return ret == {} + + def refresh_cdn(self, keys: List[str]) -> dict: + """刷新 CDN 缓存""" + urls = [self.get_file_url(key) for key in keys] + return self.cdn.refresh_urls(urls) + +# 全局单例 +_qiniu_service: Optional[QiniuService] = None + +def get_qiniu_service() -> QiniuService: + global _qiniu_service + if _qiniu_service is None: + _qiniu_service = QiniuService() + return _qiniu_service +``` + +### 6.2 FastAPI 路由集成 + +```python +# app/api/v1/qiniu.py + +from fastapi import APIRouter, UploadFile, File +from app.services.qiniu_service import get_qiniu_service + +router = APIRouter(prefix="/qiniu", tags=["Qiniu"]) + +@router.post("/upload-token") +async def get_upload_token(key: str, expires: int = 3600): + """获取客户端直传 Token""" + service = get_qiniu_service() + token = service.get_upload_token(key, expires) + return {"token": token, "key": key} + +@router.post("/upload") +async def upload_file(file: UploadFile = File(...), key: str = None): + """服务端上传文件(小文件场景)""" + import tempfile + import shutil + + service = get_qiniu_service() + + # 生成唯一文件名 + if key is None: + import uuid + ext = file.filename.split('.')[-1] if '.' in file.filename else '' + key = f"uploads/{uuid.uuid4()}.{ext}" if ext else f"uploads/{uuid.uuid4()}" + + # 保存临时文件 + with tempfile.NamedTemporaryFile(delete=False) as tmp: + shutil.copyfileobj(file.file, tmp) + tmp_path = tmp.name + + try: + result = service.upload_file(tmp_path, key) + return result + finally: + os.unlink(tmp_path) + +@router.delete("/files/{key:path}") +async def delete_file(key: str): + """删除文件""" + service = get_qiniu_service() + success = service.delete_file(key) + return {"success": success} +``` + +--- + +## 7. 最佳实践 + +### 7.1 文件名规范 + +```python +def generate_key(file_type: str, user_id: str, filename: str) -> str: + """ + 生成规范的文件存储路径 + + 格式: {type}/{user_id}/{date}/{uuid}.{ext} + """ + import uuid + from datetime import datetime + + ext = filename.split('.')[-1] if '.' in filename else 'bin' + date = datetime.now().strftime('%Y%m') + unique_id = str(uuid.uuid4())[:8] + + return f"{file_type}/{user_id}/{date}/{unique_id}.{ext}" + +# 使用示例 +key = generate_key("voices", "user_123", "my-voice.mp3") +# 结果: voices/user_123/202501/a1b2c3d4.mp3 +``` + +### 7.2 错误处理 + +```python +from qiniu import AuthError, HTTPError + +def handle_qiniu_error(func): + """七牛云操作错误处理装饰器""" + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except AuthError as e: + raise Exception(f"认证失败: {e}") + except HTTPError as e: + raise Exception(f"请求失败: {e}") + except Exception as e: + raise Exception(f"操作失败: {e}") + return wrapper +``` + +### 7.3 安全配置 + +1. **密钥管理**: 使用环境变量,禁止硬编码 +2. **Token 有效期**: 上传 Token 建议 1 小时,下载 Token 根据场景设置 +3. **上传策略**: 限制文件大小和 MIME 类型 +4. **私有空间**: 敏感文件使用私有空间 + 临时 URL + +--- + +## 8. 常见问题 + +### Q1: 上传失败,返回 401 错误? + +**A**: 检查 AccessKey 和 SecretKey 是否正确,以及 Token 是否过期。 + +### Q2: 如何支持大文件上传? + +**A**: 使用分片上传 v2 (`put_file_v2`),SDK 会自动处理分片和断点续传。 + +### Q3: 文件上传后如何获取访问 URL? + +**A**: 公有空间直接拼接 `https://{domain}/{key}`,私有空间使用 `auth.private_download_url()` 生成临时 URL。 + +### Q4: 如何刷新 CDN 缓存? + +**A**: 使用 `CdnManager.refresh_urls()` 或 `refresh_dirs()`,注意目录刷新有每日限额。 + +### Q5: 上传回调不生效? + +**A**: 确保 callbackUrl 是公网可访问的 HTTPS 地址,且返回 Content-Type: application/json。 + +--- + +## 9. 参考资料 + +- [七牛云 Python SDK 官方文档](https://developer.qiniu.com/kodo/1242/python) +- [上传策略文档](https://developer.qiniu.com/kodo/1206/put-policy) +- [表单上传 API](https://developer.qiniu.com/kodo/1272/api-overview) +- [Python SDK GitHub](https://github.com/qiniu/python-sdk) diff --git a/docs/semantic-refactoring-plan.md b/docs/semantic-refactoring-plan.md new file mode 100644 index 0000000..d9846d2 --- /dev/null +++ b/docs/semantic-refactoring-plan.md @@ -0,0 +1,425 @@ +# 后端语义治理与架构重构计划 + +> **范围**:`python-api/app/` 全目录 +> **目标**:根治需求调整与 Celery→Async Scheduler 迁移导致的命名腐败、语义漂移、类型漂移问题 +> **原则**:长期稳定优先,不计短期开发成本 +> **状态**:计划中(待团队评审后执行) + +--- + +## 一、问题诊断 + +经过深度代码扫描,当前代码存在六大类语义腐败,按严重程度排序: + +### P0:调度器核心模型的 `params: dict[str, Any]` 类型漂移 +- **症状**:所有 Handler 从裸字典里 `params.get("shots")`,且因 Redis 序列化反复写 `if isinstance(shots, str): shots = json.loads(shots)` +- **风险**:这是 Scheduler 取代 Celery 后最脆弱的环节,任何字段名改动都会导致运行时崩溃 +- **关键文件**:`app/scheduler/models.py`, `app/scheduler/registry.py`, `app/scheduler/handlers/video_handler.py` + +### P0:`shot/segment/scene` 的三重命名 + 四处重复定义 +- **症状**:`ScriptShot`(Schema)、`ShotData`(API)、`ShotTask`(Service)、`ShotUnit`(Scheduler)四者并存,字段名 `scene`/`scene_desc`、`type`/`shot_type` 混用 +- **风险**:任何分镜字段改动必须改 4 个类,极易遗漏 +- **关键文件**:`app/schemas/script.py`, `app/api/v1/video.py`, `app/services/kling_video_service.py`, `app/scheduler/models.py` + +### P0:Kling 供应商语义大规模泄漏到业务层和 API 层 +- **症状**:`element_id`(Kling 主体 ID)、`kling_task_id`、Omni prompt 语法 `<<>>` 直接出现在 API Schema、Scheduler 模型、数据库模型中 +- **风险**:一旦更换视频供应商,影响面会穿透所有层级 +- **关键文件**:`app/api/v1/video.py`, `app/api/v1/tasks.py`, `app/models/avatar.py`, `app/scheduler/handlers/video_handler.py` + +### P1:`task` / `task_id` 的五重语义混用 +- **症状**:FastAPI BackgroundTask、Scheduler Task、Kling API Task、AnyToCopy Task、Volcengine Task 都叫 `task` +- **风险**:日志堆栈中无法区分层级,调试极其困难 +- **关键文件**:`app/api/v1/tasks.py`, `app/scheduler/`, `app/ai/providers/klingai_provider.py`, `app/services/` + +### P1:历史残留命名与双轨运行 +- **症状**:`# 兼容旧字段`、`video_task_id`、`image_task_id`、`cache_err` 等 Celery 时代残留;`script` 任务仍走 BackgroundTask,其他任务走 Scheduler +- **风险**:双轨运行导致统一监控、重试、日志无法覆盖全部任务类型 +- **关键文件**:`app/scheduler/handlers/video_handler.py`, `app/scheduler/handlers/image_handler.py`, `app/api/v1/tasks.py` + +### P2:CRUD 层裸字典更新 +- **症状**:`avatar_crud.update(db, db_obj=avatar, obj_in={"status": "element_pending"})` +- **风险**:拼写错误、状态值非法无法被静态检查捕获 +- **关键文件**:`app/crud/base.py`, `app/crud/avatar.py`, `app/scheduler/handlers/avatar_handler.py` + +--- + +## 二、架构目标:六层语义治理 + +我们将整个后端严格划分为 **6 个语义层级**,每一层只使用属于该层的术语: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Layer 6: Presentation (API Schema / 前端适配层) │ +│ 术语: Segment, Human, Job, Script, Cover │ +├─────────────────────────────────────────────────────────┤ +│ Layer 5: Application (API 路由 / BackgroundJob) │ +│ 术语: Segment, Human, Job, Project │ +├─────────────────────────────────────────────────────────┤ +│ Layer 4: Orchestration (Scheduler / SlotManager) │ +│ 术语: Job, JobRecord, Slot, Handler │ +├─────────────────────────────────────────────────────────┤ +│ Layer 3: Domain (Service / 业务逻辑) │ +│ 术语: Segment, Human, VideoComposition, Caption │ +├─────────────────────────────────────────────────────────┤ +│ Layer 2: Adapter (Provider Client / 供应商适配) │ +│ 术语: KlingJob, KlingElement, VolcJob, ProviderTaskId │ +├─────────────────────────────────────────────────────────┤ +│ Layer 1: Infrastructure (DB / Redis / HTTP / FileSys) │ +│ 术语: 仅使用底层技术术语 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 核心禁令 + +1. `element`、`omni`、`kling_task_id` 等**供应商术语**禁止出现在 Layer 3 以上 +2. `shot` 禁止出现在 Layer 3 以上(Kling 术语,业务层统一叫 `segment`) +3. `task` 禁止出现在 Layer 4(Scheduler 内部统一叫 `job`) +4. `dict[str, Any]` 禁止出现在跨层传递的接口中 + +--- + +## 三、重构阶段(Phase 1-5) + +每个 Phase 独立成组,建议按顺序执行。每个 Phase 完成后必须跑通 `pytest` 和 `make lint`。 + +--- + +### Phase 1:Schema 统一 + 状态机 Enum 化 +**目标**:建立"单一真相源",消除 shot/segment/scene 的四重定义 +**预估工时**:3-4 天 +**影响面**:全项目基础类型 + +#### Task 1.1:新建统一术语 Schema +- [ ] 新建 `app/schemas/segment.py` + ```python + class Segment(BaseModel): + id: str + type: Literal["segment", "empty_shot"] + scene: str # 统一为 scene,删除 scene_desc + voiceover: str + duration: int | None = None + human_id: str | None = None # 业务术语,对应 Kling 的 element_id + status: SegmentStatus = SegmentStatus.PENDING + provider_task_id: str | None = None + video_url: str | None = None + local_path: str | None = None + query_fail_count: int = 0 + ``` +- [ ] 新建 `app/schemas/enums.py`,定义以下 Enum: + - `JobStatus`: pending, running, completed, failed + - `SegmentStatus`: pending, submitted, completed, failed + - `AvatarCloneStatus`: pending, voice_processing, voice_failed, element_pending, element_processing, element_failed, succeed + - `KlingTaskStatus`: submitted, processing, succeed, failed(仅限 Provider 层使用) +- [ ] 新建 `app/schemas/job.py`,定义 `JobParams` Union: + - `VideoJobParams`(含 `segments: list[Segment]`) + - `ImageJobParams` + - `SubtitleJobParams` + - `CopyJobParams` + - `AvatarCloneJobParams` + +#### Task 1.2:删除重复定义 +- [ ] 删除 `app/scheduler/models.py` 中的 `ShotUnit` +- [ ] 删除/重构 `app/services/kling_video_service.py` 中的 `ShotTask`(字段迁移到 `Segment`) +- [ ] 删除 `app/api/v1/video.py` 中的 `ShotData`,改为引用 `Segment` +- [ ] 将 `app/schemas/script.py` 中的 `ScriptShot` 改为 `Segment` 的别名或类型适配器 + +#### Task 1.3:字段名统一 +- [ ] 批量将 `scene_desc` 重命名为 `scene`(覆盖 `kling_video_service.py`, `video_handler.py` 等) +- [ ] 批量将 `shot_type` 重命名为 `type`(在业务层/Schema 层;Provider 层保留 `shot_type` 仅用于 Kling API 调用) +- [ ] `app/api/v1/tasks.py` 中的 `shots: list[dict]` 改为 `segments: list[Segment]` + +#### Task 1.4:状态字符串 Enum 化 +- [ ] `app/scheduler/models.py` 中 `TaskRecord.status` 类型改为 `JobStatus` +- [ ] `app/services/kling_video_service.py` 中 `VideoGenerationJob.status` 类型改为 `JobStatus` +- [ ] `app/models/avatar.py` 中 `Avatar.status` 类型改为 `AvatarCloneStatus` +- [ ] `app/ai/providers/klingai_provider.py` 中所有 Kling 状态字符串操作改为 `KlingTaskStatus` + +#### 验收标准 +- [ ] `grep -rn "class ShotUnit\|class ShotTask\|class ShotData\|class ScriptShot" app/` 返回为空(除了注释或别名声明) +- [ ] `grep -rn "scene_desc" app/ | grep -v "__pycache__"` 返回为空 +- [ ] `mypy app/schemas/` 无类型错误 +- [ ] `pytest` 通过 + +--- + +### Phase 2:Scheduler 层全面"去 task 化" +**目标**:消除 `task` 的五重语义混用,建立 `Job` 专属语义域 +**预估工时**:3-4 天 +**影响面**:`app/scheduler/` 目录及引用方 + +#### Task 2.1:核心模型与 Registry 重命名 +- [ ] `app/scheduler/models.py`:`TaskRecord` → `JobRecord` +- [ ] `app/scheduler/registry.py`:`TaskRegistry` → `JobRegistry` + - 所有 `task_id` 参数/字段 → `job_id` + - 所有 `task_type` 参数/字段 → `job_type` + - `running_task_ids` → `running_job_ids` +- [ ] `app/scheduler/engine.py`:`AsyncEngine` 中所有 `task` → `job` + +#### Task 2.2:Registry 承担全部序列化职责 +- [ ] 在 `JobRegistry.get()` 中统一完成 JSON 反序列化 + - 解析 `params` 字段时,根据 `job_type` 路由到正确的 `JobParams` Pydantic 模型 + - 保证 Handler 拿到的 `job.params` 永远是强类型对象 +- [ ] 删除 `video_handler.py` 和 `image_handler.py` 中所有的 `isinstance(shots, str): json.loads` 逻辑 +- [ ] 删除 `_download_and_upload` 中的手动 JSON 类型判断 + +#### Task 2.3:`StateChange` 取代裸字典 +- [ ] 在 `app/scheduler/models.py` 中定义: + ```python + @dataclass(frozen=True) + class StateChange: + job_id: str + field: str + value: Any + ``` +- [ ] 修改 `app/scheduler/engine.py`: + - `_apply_changes(self, changes: list[dict[str, Any]])` → `_apply_changes(self, changes: list[StateChange])` + - 序列化逻辑移入 `StateChange.to_redis_command()` 方法 +- [ ] 修改 `app/scheduler/handlers/base.py`:`tick()` 返回类型改为 `list[StateChange]` +- [ ] 修改所有 Handler:`changes.append({"task_id": ..., "field": ...})` → `changes.append(StateChange(job_id=..., field=..., value=...))` + +#### Task 2.4:API 层适配 +- [ ] `app/api/v1/tasks.py` 中:内部变量名 `task_id` 在引用 Scheduler 时改为 `job_id`(API URL `/tasks/{task_id}` 保持不变,仅内部变量和注释调整) +- [ ] `app/api/v1/avatar.py` 中:引用 `TaskRegistry` 的地方改为 `JobRegistry` + +#### 验收标准 +- [ ] `grep -rn "TaskRecord\|TaskRegistry\|running_task_ids" app/scheduler/` 返回为空 +- [ ] `grep -rn "isinstance(.*shots.*str)" app/scheduler/handlers/` 返回为空 +- [ ] `grep -rn '"task_id":' app/scheduler/handlers/` 返回为空(仅 `StateChange` dataclass 内部可保留) +- [ ] `pytest` 通过 + +--- + +### Phase 3:建立"供应商防火墙"(Adapter 层隔离) +**目标**:将 Kling/Volc 术语彻底下压到 Provider 层,业务层以上使用纯业务语义 +**预估工时**:4-5 天 +**影响面**:API Schema、DB 模型、Scheduler 模型、Provider 层 + +#### Task 3.1:API 层删除 Kling 术语泄漏 +- [ ] `app/api/v1/video.py`: + - `element_id: int | None = Field(None, description="Kling主体ID")` → `human_id: int | None = Field(None, description="数字人主体ID")` +- [ ] `app/api/v1/tasks.py`: + - 同上,所有 `element_id` → `human_id` + - `VideoParams` 中的 `shots` → `segments` + +#### Task 3.2:DB 模型增加供应商抽象 +- [ ] `app/models/avatar.py`: + - `element_id: Mapped[int | None]` → `provider_element_id: Mapped[int | None]` + - `voice_task_id` → `provider_voice_job_id` + - `element_task_id` → `provider_element_job_id` + - 新增 `provider: Mapped[str] = mapped_column(default="kling")`(为未来多供应商做准备) +- [ ] 生成 Alembic 迁移脚本(字段重命名 + 新增字段) + +#### Task 3.3:Scheduler 模型供应商抽象 +- [ ] `app/scheduler/models.py`(Phase 2 后的 `JobRecord` 及 `Segment`): + - `kling_task_id` → `provider_task_id` + - 如需追溯供应商,增加 `provider: str = "kling"` + +#### Task 3.4:Provider 返回值模型化 +- [ ] 新建 `app/ai/providers/kling_dto.py`: + - `KlingVideoResult` + - `KlingImageResult` + - `KlingVoiceResult` + - `KlingElementResult` +- [ ] 修改 `app/ai/providers/klingai_provider.py`: + - 所有返回裸 `dict[str, Any]` 的方法改为返回对应的 `Kling*Result` + - 状态字段类型改为 `KlingTaskStatus` + +#### Task 3.5:Prompt 语法迁移到 Provider 层 +- [ ] 删除 `app/scheduler/handlers/video_handler.py` 中的: + - `_build_omni_prompt()` + - `_build_empty_shot_video_prompt()` +- [ ] 在 `app/ai/providers/klingai_provider.py` 中新建 `KlingPromptBuilder` 类: + ```python + class KlingPromptBuilder: + @staticmethod + def omni_segment(scene: str, voiceover: str, element_id: str | None = None) -> str + @staticmethod + def empty_shot(scene: str, voiceover: str) -> str + ``` +- [ ] `video_handler.py` 调用时只传业务字段(`scene`, `voiceover`, `human_id`),由 Provider 层负责映射为 Kling 语法 + +#### Task 3.6:Service 层适配器映射 +- [ ] `app/services/kling_video_service.py`: + - 删除 `avatar_id` 废弃字段 + - `human_id` → 在调用 Provider 时映射为 `element_id` +- [ ] `app/services/qiniu_service.py`: + - `file_type="avatar"` → `file_type="clone_video"` 或 `"human_video"` + +#### 验收标准 +- [ ] `grep -rn "element_id" app/api/ app/schemas/ app/scheduler/models.py | grep -v "provider_element_id"` 返回为空 +- [ ] `grep -rn "kling_task_id" app/api/ app/schemas/ app/scheduler/models.py` 返回为空 +- [ ] `grep -rn "<<>>\|<<>>" app/scheduler/ app/services/` 返回为空(仅在 Provider 层保留) +- [ ] Alembic 迁移脚本可正常升级/降级 +- [ ] `pytest` 通过 + +--- + +### Phase 4:清理历史残留 + 消除双轨运行 +**目标**:删除所有 Celery 时代残留,将 `script` 任务纳入 Scheduler 统一调度 +**预估工时**:2-3 天 +**影响面**:Handler、API 路由、历史命名 + +#### Task 4.1:删除兼容旧字段代码 +- [ ] `app/scheduler/handlers/video_handler.py`: + - 删除 `shot["video_task_id"] = kling_task_id # 兼容旧字段` + - 删除初始化 shots 时的 `"video_task_id": None` +- [ ] `app/scheduler/handlers/image_handler.py`: + - 删除 `params["image_task_id"] = kling_task_id` +- [ ] `app/services/kling_video_service.py`: + - 删除 `avatar_id` 字段 + +#### Task 4.2:修正历史残留命名 +- [ ] `app/core/redis_client.py`:删除文档字符串中的 `RateLimiter` 字样 +- [ ] `app/api/v1/tasks.py`: + - `cache entry` → `registry entry` + - `cache_err` → `registry_err` + - `Failed to update cache` → `Failed to update registry` +- [ ] `app/core/token_manager.py`:`_background_tasks` → `_refresh_tasks` +- [ ] 删除 `app/services/dto.py` + +#### Task 4.3:将 `script` 任务迁移到 Scheduler +- [ ] 新建 `app/scheduler/handlers/script_handler.py` + - 将 `app/api/v1/tasks.py` 中 `_run_script_task` 的逻辑迁移至此 + - 继承 `AsyncHandler`,`name = "script"`,不占用 Slot(或占用独立 `script_slots`) +- [ ] 修改 `app/api/v1/tasks.py`: + - `script` 任务改为 `registry.create(job_type="script", ...)` + - 删除 `BackgroundTasks` 相关参数和 `_run_script_task` 函数 +- [ ] 修改 `app/scheduler/main.py`:注册 `ScriptHandler` + +#### 验收标准 +- [ ] `grep -rn "兼容旧字段\|video_task_id\|image_task_id" app/scheduler/ app/services/` 返回为空 +- [ ] `grep -rn "cache_err\|cache entry" app/api/v1/tasks.py` 返回为空 +- [ ] `app/services/dto.py` 不存在 +- [ ] `app/api/v1/tasks.py` 中无 `BackgroundTasks` 导入和使用 +- [ ] `pytest` 通过 + +--- + +### Phase 5:CRUD 层强类型化 +**目标**:消灭 CRUD 层的裸字典更新 +**预估工时**:2 天 +**影响面**:CRUD Base、Avatar CRUD、Scheduler Handler + +#### Task 5.1:CRUD Base 类型约束 +- [ ] `app/crud/base.py`: + - `obj_in: dict[str, Any]` → `obj_in: CreateSchemaType | UpdateSchemaType` + - 保留 `dict` 仅作为 `update` 的 fallback,但所有业务调用方优先使用 Schema + +#### Task 5.2:Avatar Schema 定义 +- [ ] 新建 `app/schemas/avatar.py`: + ```python + class AvatarCreate(BaseModel): + name: str + video_url: str + status: AvatarCloneStatus = AvatarCloneStatus.PENDING + + class AvatarUpdate(BaseModel): + name: str | None = None + status: AvatarCloneStatus | None = None + provider_voice_job_id: str | None = None + provider_element_job_id: str | None = None + provider_element_id: int | None = None + fail_reason: str | None = None + ``` +- [ ] `app/crud/avatar.py`:改为 `class CRUDAvatar(CRUDBase[Avatar, AvatarCreate, AvatarUpdate])` + +#### Task 5.3:Handler 调用方改造 +- [ ] `app/scheduler/handlers/avatar_handler.py`: + - 所有 `_update_avatar(avatar_id, {"status": "..."})` 改为 `_update_avatar(avatar_id, AvatarUpdate(status=AvatarCloneStatus.XXX))` + - 删除裸字典辅助函数 `_update_avatar` 中的 `**obj_in` 展开,改用 `obj_in.model_dump(exclude_unset=True)` + +#### 验收标准 +- [ ] `grep -rn 'obj_in=\{' app/scheduler/handlers/avatar_handler.py` 返回为空 +- [ ] `mypy app/crud/` 无类型错误 +- [ ] `pytest` 通过 + +--- + +## 四、自动化防护网(Phase 5 之后部署) + +### 4.1 预提交钩子:禁词检查 +在 `.pre-commit-config.yaml` 或 `Makefile` 中增加 `lint-semantic`: + +```makefile +lint-semantic: + @echo "Checking semantic boundaries..." + @! grep -rn "element_id" app/api/ app/schemas/ app/scheduler/models.py | grep -v "provider_element_id" || (echo "ERROR: element_id leaked to upper layers"; exit 1) + @! grep -rn "kling_task_id" app/api/ app/schemas/ app/scheduler/models.py || (echo "ERROR: kling_task_id leaked to upper layers"; exit 1) + @! grep -rn "scene_desc" app/ | grep -v "__pycache__" || (echo "ERROR: scene_desc not fully renamed"; exit 1) + @! grep -rn "TaskRecord\|TaskRegistry\|running_task_ids" app/scheduler/ || (echo "ERROR: Scheduler task naming not fully migrated"; exit 1) + @! grep -rn "<<>>\|<<>>" app/scheduler/ app/services/ || (echo "ERROR: Kling prompt syntax leaked"; exit 1) + @echo "Semantic check passed" +``` + +### 4.2 mypy 渐进严格化 +- [ ] 在 `pyproject.toml` 中为 `app/scheduler/` 和 `app/schemas/` 单独开启 `strict = true` +- [ ] 逐步扩展至 `app/api/` 和 `app/services/` + +### 4.3 AGENTS.md 术语表(Glossary) +在 `AGENTS.md` 中新增"统一术语表"章节(见下文),所有 AI Agent 修改代码前必须查阅。 + +--- + +## 五、风险与回滚策略 + +| 风险 | 影响 | mitigation | +|------|------|-------------| +| Phase 1 删除 `ScriptShot` 影响前端类型生成 | 中 | `ScriptShot` 保留为 `Segment` 的 `TypeAlias` 一个 Sprint,待前端适配后删除 | +| Phase 2 `JobRegistry` 重命名导致 API 层引用遗漏 | 高 | 使用 IDE 全局重构(PyCharm/Ruff/Rename Symbol),执行后跑全量 `pytest` | +| Phase 3 DB 字段重命名需要数据迁移 | 中 | Alembic 脚本必须包含 `op.alter_column` 的 `existing_type` 和 `existing_nullable` | +| Phase 4 `script` 迁出 BackgroundTask 后响应时间变长 | 低 | 脚本生成仍立即返回 `job_id`,前端通过轮询 `/tasks/{job_id}` 获取结果,接口契约不变 | +| 多 Phase 并行开发导致冲突 | 高 | **严禁并行**:必须按 1→2→3→4→5 顺序执行,每个 Phase 合并到主分支后再开始下一个 | + +--- + +## 六、作为 GitHub Project Task List 的格式 + +若导入 GitHub Project,建议按以下结构建 5 个 Milestone: + +```markdown +### Milestone 1: Schema Unification +- [ ] #101 Create `app/schemas/segment.py` with `Segment` model +- [ ] #102 Create `app/schemas/enums.py` with `JobStatus`, `SegmentStatus`, `AvatarCloneStatus`, `KlingTaskStatus` +- [ ] #103 Create `app/schemas/job.py` with `JobParams` Union +- [ ] #104 Remove `ShotUnit` from `app/scheduler/models.py` +- [ ] #105 Remove `ShotTask` from `app/services/kling_video_service.py` +- [ ] #106 Remove `ShotData` from `app/api/v1/video.py` +- [ ] #107 Rename `scene_desc` → `scene` across codebase +- [ ] #108 Migrate all `status` strings to Enums + +### Milestone 2: Scheduler De-tasking +- [ ] #201 Rename `TaskRecord` → `JobRecord` +- [ ] #202 Rename `TaskRegistry` → `JobRegistry` +- [ ] #203 Registry auto-deserializes `JobParams` models +- [ ] #204 Replace `dict` changes with `StateChange` dataclass +- [ ] #205 Update all Handlers to return `list[StateChange]` + +### Milestone 3: Vendor Firewall +- [ ] #301 API layer: `element_id` → `human_id` +- [ ] #302 DB model: add `provider` field, rename task/element IDs +- [ ] #303 Scheduler model: `kling_task_id` → `provider_task_id` +- [ ] #304 Provider DTOs: `KlingVideoResult`, `KlingImageResult`, etc. +- [ ] #305 Move `KlingPromptBuilder` to Provider layer +- [ ] #306 Alembic migration for avatar table changes + +### Milestone 4: Cleanup & Unification +- [ ] #401 Remove legacy compatibility fields (`video_task_id`, `image_task_id`) +- [ ] #402 Fix historical naming (`cache_err`, `RateLimiter` docstrings, etc.) +- [ ] #403 Delete `app/services/dto.py` +- [ ] #404 Migrate `script` task from BackgroundTask to Scheduler + +### Milestone 5: CRUD Strong Typing +- [ ] #501 Create `AvatarCreate` / `AvatarUpdate` schemas +- [ ] #502 Type-constrain CRUDBase +- [ ] #503 Refactor `avatar_handler.py` to use `AvatarUpdate` instead of raw dicts +- [ ] #504 Add `lint-semantic` to Makefile / pre-commit +- [ ] #505 Update `AGENTS.md` with Glossary and layer rules +``` + +--- + +## 七、相关文档 + +- [统一异步调度器设计文档](./unified-async-scheduler.md) +- [数据库设计文档](./database-design.md) +- [AGENTS.md](../AGENTS.md)(术语表与分层禁令) diff --git a/docs/unified-async-scheduler.md b/docs/unified-async-scheduler.md new file mode 100644 index 0000000..9bc8d19 --- /dev/null +++ b/docs/unified-async-scheduler.md @@ -0,0 +1,350 @@ +# 统一异步任务调度方案 + +> **状态:已完成(2026-04-17)** — 本文档所述方案已全面实施,Celery 已完全移除,所有第三方异步任务现由 Async Engine Scheduler 统一调度。 + +> 本文档用于替代原 Celery 在"提交→轮询→收尾"类第三方异步任务中的角色,解决视频生成、形象克隆等任务在队列中频繁出现的拥堵、死锁和状态不一致问题。 + +--- + +## 1. 背景与问题 + +### 1.1 当前架构的缺陷 + +目前项目使用 Celery Worker 处理所有第三方异步任务,包括: + +- **视频生成** (`video`):提交 Kling 分镜 → 轮询状态 → 下载上传 +- **形象克隆** (`avatar_clone`):提交音色 → 轮询 → 提交主体 → 轮询 +- **字幕对齐** (`subtitle`) +- **图片生成** (`image`) + +这些任务都被放进 Celery 队列,由 Worker 并发消费。但 Kling 视频生成本质上是**"占用并发槽位并长时间等待"**的过程,当前设计存在三个结构性问题: + +1. **轮询任务风暴**:`poll_video_task` 用 Celery `retry(countdown=5)` 模拟轮询,一个 8 分钟的 Kling 任务会产生近百个 Celery Task,淹没队列调度器和 Redis Result Backend。 +2. **快慢任务混排**:下载上传(IO 密集型)和提交/轮询(轻量 HTTP)共用 `video` 队列,Worker 被长任务占满,新任务饿死。 +3. **状态死锁**:`download_upload_shot` 作为独立 Celery Task,一旦被 Worker 强制 Kill(如超时),shot 状态永远卡在 `downloading`,而轮询任务又不再处理它,导致整个任务假死。 + +### 1.2 核心认知 + +Kling 是一个有**严格并发上限**(20 槽位)的第三方异步执行池。我们需要的是一个 **Slot-Based Scheduler**(槽位调度器),而不是一个任务队列(Celery)。 + +> **任务队列**擅长"把独立任务尽快分发出去"; +> **槽位调度器**擅长"在有限资源下,周期性补货、轮询和收尾"。 + +Kling 视频生成和形象克隆属于后者。 + +--- + +## 2. 架构总览 + +``` +┌─────────────┐ HTTP ┌──────────────────┐ +│ Tauri App │ ◄────────────► │ FastAPI API │ +│ (React) │ │ (Gateway) │ +└─────────────┘ └────────┬─────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ PostgreSQL │ │ Redis │ │ Object Store │ + │ (持久化/审计) │ │ (运行时状态) │ │ (七牛/本地) │ + └──────────────┘ └──────┬───────┘ └──────────────┘ + │ + ┌────────┴────────┐ + │ Async Engine │ + │ (Slot Scheduler)│ + │ python main.py │ + └────────┬────────┘ + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ Video Handler│ │Avatar Handler│ │Future Handler│ + │ max_slots=18│ │ max_slots=2 │ │ (subtitle…) │ + └─────────────┘ └─────────────┘ └─────────────┘ +``` + +### 2.1 核心组件 + +| 组件 | 职责 | 技术选型 | +|------|------|----------| +| **FastAPI API** | 接收前端请求、创建任务、写入状态、供前端轮询 | 现有 FastAPI | +| **Redis** | 存储任务的**运行时状态**(running shots、当前 stage、slot 占用集合) | 现有 Redis | +| **PostgreSQL** | 存储任务的**持久化记录**(创建时间、最终结果、成本统计、失败原因) | 现有 PostgreSQL | +| **Async Engine** | 独立的调度进程,每 10 秒一次 **Tick**,驱动所有任务状态推进 | Python `asyncio` | +| **Handler** | 插件化模块,每个第三方平台一个实现 | 面向接口的 Python 类 | + +--- + +## 3. 核心机制 + +### 3.1 统一状态机 + +无论 video 还是 avatar_clone,所有第三方异步任务单元收敛到 **5 个统一状态**: + +``` +pending → submitted → succeed → completed + │ │ + └──────────────────────────────┘ + ↓ + failed +``` + +- **`pending`**:在队列里等待全局 slot 空闲 +- **`submitted`**:已占用 slot,已提交给 Kling,等待轮询结果 +- **`succeed`**:Kling 返回成功,Async Engine 立即触发下载/收尾(后台异步执行) +- **`failed`**:Kling 返回失败或提交异常 +- **`completed`**:下载、上传、DB 写入全部完成 + +对于 avatar_clone 这种**多阶段**任务,内部用 Sub-State 嵌套,但每个阶段仍遵循同一模式: + +``` +voice_pending → voice_submitted → voice_succeed + ↓ + element_pending → element_submitted → element_succeed → completed +``` + +### 3.2 Slot Manager(全局并发控制器) + +基于 **Redis SET + Lua 脚本** 实现严格的原子槽位管理: + +```python +class SlotManager: + async def acquire(self, slot_key: str, slot_id: str, max_slots: int) -> bool: + """Lua 脚本原子执行:SADD -> SCARD -> 超限则 SREM""" + lua = """ + local key = KEYS[1] + local slot_id = ARGV[1] + local max_slots = tonumber(ARGV[2]) + redis.call('sadd', key, slot_id) + local count = redis.call('scard', key) + if count > max_slots then + redis.call('srem', key, slot_id) + return 0 + end + redis.call('expire', key, 1800) + return 1 + """ + return await self.redis.eval(lua, 1, slot_key, slot_id, str(max_slots)) == 1 + + async def release(self, slot_key: str, slot_id: str) -> None: + await self.redis.srem(slot_key, slot_id) +``` + +当前配置: + +- **Video 槽位池**:`kling:video_slots`,上限 **18** +- **Avatar 槽位池**:`kling:avatar_slots`,上限 **2** + +> **为什么 Lua 脚本?** 确保 `SADD + SCARD + 条件 SREM` 原子执行。即使未来启动第二个 Scheduler 实例做 HA,也不会出现并发超发。 + +### 3.3 Async Engine Tick 循环 + +Scheduler 是一个独立的 `asyncio` 进程,主循环如下: + +```python +async def main(): + engine = AsyncEngine() + while True: + tick_start = time.monotonic() + + # 1. 加载所有 running 的任务 + tasks = await engine.registry.get_running_tasks() + + # 2. 按 Handler 分组,并行执行各自的 tick + changes = await asyncio.gather(*[ + handler.tick(tasks_for_handler, engine.slots) + for handler in engine.handlers.values() + ]) + + # 3. 批量应用状态变更(Pipeline 写入 Redis) + await engine.registry.apply_changes(flatten(changes)) + + # 4. 对 completed/failed 的任务,持久化到 PostgreSQL + await engine.persist_finished_tasks() + + # 5. 控制 Tick 间隔(固定 10 秒,执行过久时至少休息 2 秒) + elapsed = time.monotonic() - tick_start + await asyncio.sleep(max(10 - elapsed, 2)) +``` + +### 3.4 Handler 插件化接口 + +每个第三方平台实现一个 Handler: + +```python +class AsyncHandler(ABC): + name: str # e.g. "video" + slot_key: str # e.g. "kling:video_slots" + max_slots: int # e.g. 18 + + @abstractmethod + async def tick(self, tasks: list[Task], slots: SlotManager) -> list[StateChange]: + """每个 Tick 执行一次,返回需要更新的状态变更列表""" + pass +``` + +#### Video Handler 的 tick 逻辑 + +1. **查**:遍历所有 `submitted` 的 shots,批量并行查询 Kling 状态 +2. **收**: + - `succeed` → `release_slot` + `asyncio.create_task(download_and_upload(shot))` + 状态改为 `completed` + - `failed` → `release_slot` + 状态改为 `failed` +3. **补**:计算空闲槽位数,从 `pending` 队列 FIFO 取出新 shot 提交给 Kling,直到槽满 +4. **写**:更新 task 的聚合状态(completed/total/message)到 Redis + +#### Avatar Handler 的 tick 逻辑 + +1. 检查当前 stage(如 `voice_submitted`),查询 Kling 状态 +2. 若 `voice_succeed` → 释放 slot,推进到 `element_pending`,并在同一 tick 内尝试申请 slot 提交主体创建 +3. 若 `element_succeed` → 释放 slot,状态改为 `completed` + +--- + +## 4. API 层与数据流 + +### 4.1 创建任务(不变) + +```python +@router.post("/{task_type}", response_model=TaskCreateResponse) +async def create_task(task_type: str, request: TaskCreateRequest): + task_id = generate_task_id() + + # 1. 写入 PostgreSQL(持久化底单) + await db_task.create(task_id=task_id, type=task_type, user_id=...) + + # 2. 写入 Redis(标记为 pending,供 Async Engine 消费) + await redis_task.create(task_id=task_id, type=task_type, status="pending", ...) + + return TaskCreateResponse(task_id=task_id, status="pending") +``` + +### 4.2 查询状态(不变) + +```python +@router.get("/{task_id}", response_model=TaskStatusResponse) +async def get_task_status(task_id: str): + # 先读 Redis(热数据) + task = await redis_task.get(task_id) + if not task: + # fallback 到 PostgreSQL(已完成的归档数据) + task = await db_task.get(task_id) + return task +``` + +### 4.3 前端兼容性 + +**前端 `useTask.ts` 的轮询逻辑完全不需要修改。** 这是渐进式迁移的关键——调度层的重构对上层透明。 + +--- + +## 5. 部署方案 + +### 5.1 Docker Compose + +```yaml +services: + api: + build: + context: ../python-api + dockerfile: Dockerfile + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + # ... + + scheduler: + build: + context: ../python-api + dockerfile: Dockerfile + container_name: meijiaka-scheduler + command: python -m app.scheduler.main + environment: + - REDIS_HOST=redis + - DATABASE_URL=postgresql+asyncpg://... + depends_on: + - redis + - db + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M +``` + +### 5.2 Celery 的处置 + +- 立即下线 `worker-video`(不再消费 `video` 队列) +- Phase 2 下线 `worker-avatar`(Avatar Handler 迁入 Async Engine 后) +- 可选:暂时保留 Celery 跑 `subtitle`,待后续迁移 +- 最终目标:所有"提交→轮询"类任务都迁入 Async Engine,Celery 整体移除 + +--- + +## 6. 迁移路径 + +| 阶段 | 时间 | 动作 | 风险 | +|------|------|------|------| +| **Phase 1** | 本周 | Async Engine 只接管 `video`(18 slots);`avatar_clone` 仍由 Celery 运行 | 改动面最小,只验证 video 链路 | +| **Phase 2** | 1-2 周后 | Async Engine 新增 `avatar_clone` Handler(2 slots);彻底下线 Celery 的 `worker-video` 和 `worker-avatar` | 验证 avatar 链路,解决资源饿死 | +| **Phase 3** | 未来 | `subtitle`、`image` 等陆续迁入 Async Engine;Celery 完全移除 | 统一所有第三方异步任务调度 | + +--- + +## 7. 设计原则论证 + +### 7.1 主流(Mainstream) + +- **Redis + PostgreSQL 双存储**:运行态在 Redis,持久态在 PostgreSQL。这是现代异步系统的事实标准,从 AWS Lambda 到 Vercel 再到国内云厂商均采用类似模式。 +- **Python asyncio 轻量调度器**:不引入 Kafka、RabbitMQ 或 Airflow 等重型框架,利用原生异步能力构建。Prefect、Dagster 的底层 Scheduler 也采用类似思想。 +- **Gateway + 独立 Scheduler 进程**:API 负责接入,Scheduler 负责推进,职责清晰。这是当前中小型 SaaS 的主流演进方向。 + +### 7.2 合理(Reasonable) + +- **完全匹配项目定位**:项目定位是"轻量云账号 + 全本地业务数据",不需要 Kubernetes 或复杂工作流引擎。Async Engine 只是一个额外的 Python 进程,资源占用 < 512MB。 +- **渐进式迁移,契约不变**:前端轮询逻辑、API URL、响应 Schema 均不变。改动仅集中在后端任务分发层,业务代码零侵入。 +- **资源隔离精确可控**:Video 18 slots + Avatar 2 slots = 20 slots,与 Kling 实际并发限制完全对齐。不会出现"形象克隆占满 Worker 导致视频饿死"的结构性问题。 +- **开发体验优先**:本地开发时,scheduler 可以和 api 一起 `docker-compose up`,也可以单独 `python -m app.scheduler.main` 调试。不需要 ngrok,不需要把开发环境搬到云端。 +- **幂等和可恢复**:每个 shot 的提交操作都是幂等的。Redis 记录了 `kling_task_id`,Scheduler 重启后从 Redis 恢复 running 任务,继续轮询,不会丢失状态。 + +### 7.3 长期稳定(Long-term Stable) + +- **HA 预留,无单点故障**:`SlotManager` 基于 Redis Lua 脚本实现原子操作,天然支持多实例竞争。未来如需高可用,可启动第二个 Scheduler 实例,通过 Redis 分布式锁选举 Leader,实现秒级主备切换,无需重构。 +- **Handler 插件化扩展**:未来接入即梦、Runway、Pika 或新的 AI 服务,只需实现新的 `AsyncHandler` 子类,配置 `slot_key` 和 `max_slots`。核心调度逻辑永远不需要改动。 +- **数据一致性保障**:运行态在 Redis(崩溃恢复快),完成态在 PostgreSQL(数据不丢)。即使 Scheduler 挂掉 30 分钟,Kling 端的任务仍在运行,恢复后继续轮询即可。 +- **第三方接口变更的防御性**:Handler 内部对 Kling API 的调用有统一的超时控制、重试策略和异常兜底。如果 Kling 某个接口升级,只改对应 Handler,不影响其他模块。 +- **可观测性支撑长期运维**:通过 Prometheus 指标,可长期监控"视频生成成功率"、"平均生成耗时"、"槽位利用率"、"Kling API 延迟分布",为后续扩容和成本优化提供数据支撑。 + +--- + +## 8. 关键文件位置(建议) + +``` +python-api/ +├── app/ +│ └── scheduler/ +│ ├── __init__.py +│ ├── main.py # Async Engine 入口(Tick 循环) +│ ├── engine.py # AsyncEngine 核心调度器 +│ ├── slot_manager.py # 槽位管理器(Redis Lua) +│ ├── registry.py # 任务注册表(Redis 读写) +│ ├── handlers/ +│ │ ├── __init__.py +│ │ ├── base.py # AsyncHandler 抽象基类 +│ │ ├── video_handler.py # Video 任务处理器 +│ │ └── avatar_handler.py # Avatar Clone 处理器 +│ └── models.py # Scheduler 内部数据模型 +``` + +--- + +## 9. 结论 + +当前 Celery 在"提交→轮询→收尾"类任务中的角色是**结构性错位**的。它带来的任务风暴、队列拥堵和状态死锁不是可以通过调参修复的,而是其模型与问题本质不匹配的结果。 + +**统一异步调度方案**的核心决策: + +1. **用 Async Engine(Slot-Based Scheduler)替代 Celery 管理所有第三方异步任务** +2. **Video 分配 18 槽,Avatar Clone 分配 2 槽,由唯一调度器全局管理** +3. **API 层和前端轮询逻辑完全不变,实现渐进式迁移** +4. **本地开发环境保持原样,无需引入 webhook 或云端部署** + +这是根治"任务队列生成视频总会出问题"的唯一长期方案。 diff --git a/docs/video-generation-flow.md b/docs/video-generation-flow.md new file mode 100644 index 0000000..a87989f --- /dev/null +++ b/docs/video-generation-flow.md @@ -0,0 +1,342 @@ +# 视频生成交互流程设计 + +## 一、正常流程(批量生成) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Step 0: 检查前置条件 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 点击【生成视频】按钮 │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 检查本地状态 │ │ +│ │ 如果正在生成中 │───► 提示"已有任务进行中,请等待完成" │ +│ └─────────────────┘ 或者"是否取消当前任务?" │ +│ │ │ +│ ▼ 无进行中任务 │ +│ ┌─────────────────┐ │ +│ │ 检查是否选形象 │ │ +│ │ 未选择 │───► 弹出形象选择弹窗 │ +│ └─────────────────┘ │ +│ │ │ +│ ▼ 已选择 │ +│ 继续下一步 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Step 1: 确认弹窗 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 开始生成视频 │ │ +│ ├─────────────────────────────────────────┤ │ +│ │ │ │ +│ │ 将生成 8 个分镜视频 │ │ +│ │ 预计耗时:约 15-20 分钟 │ │ +│ │ │ │ +│ │ ⚠️ 生成过程中请勿关闭应用 │ │ +│ │ 您可以最小化窗口,但不要关闭 │ │ +│ │ │ │ +│ │ ┌────────────┐ ┌──────────────────┐ │ │ +│ │ │ 取消 │ │ 开始生成 │ │ │ +│ │ └────────────┘ └──────────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Step 2: 进入生成状态(界面锁定) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 【生成】按钮变为【生成中...】且 disabled │ +│ │ +│ 顶部显示全局状态栏: │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 🎬 视频生成中 ━━━━━━━━⏳━━━━ 预计还需 12 分钟 [?] ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ 显示模态弹窗(不可关闭): │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 视频生成 │ │ +│ ├─────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ┌─────────────┐ │ │ +│ │ │ 状态标签 │ │ │ +│ │ │ 任务已开启 │ │ │ +│ │ └─────────────┘ │ │ +│ │ │ │ +│ │ 正在为空镜生成参考图片... │ │ +│ │ │ │ +│ │ 预计还需 12 分钟 │ │ +│ │ │ │ +│ │ [最小化到后台] │ │ +│ │ │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ 界面锁定状态: │ +│ - 禁用:生成按钮、新建项目、添加/删除分镜 │ +│ - 可浏览:但不能修改任何内容 │ +│ - 可退出应用:但会提示"任务将后台继续,确定退出?" │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Step 3: 状态流转 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 状态标签流转: │ +│ │ +│ 分镜(omni-video): │ +│ 任务已开启 ──► 排队生成中 ──► 任务已完成 │ +│ │ +│ 空镜(文生图+图生视频): │ +│ 任务已开启 ──► 生成参考图片... ──► 排队生成中 ──► 任务已完成 │ +│ │ +│ 详细描述文字实时更新(SSE 推送): │ +│ - "正在初始化任务..." │ +│ - "正在为空镜生成参考图片..." │ +│ - "图片生成完成,开始生成视频..." │ +│ - "正在生成视频,请稍候..." │ +│ - "已完成 3/8 个分镜" │ +│ - "整理生成结果..." │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Step 4: 完成 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 模态弹窗更新: │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 视频生成 │ │ +│ ├─────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ┌─────────────┐ │ │ +│ │ │ 任务已完成 │ │ │ +│ │ └─────────────┘ │ │ +│ │ │ │ +│ │ 成功生成 8 个视频 │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────┐ │ │ +│ │ │ 确定 │ │ │ +│ │ └──────────────────────────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ 用户点击【确定】: │ +│ 1. 关闭弹窗 │ +│ 2. 解锁界面 │ +│ 3. 自动滚动到第一个有视频的分镜 │ +│ 4. 播放第一个视频 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 二、单个重新生成流程 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 差异点 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 入口:分镜卡片上的【重新生成】按钮 │ +│ │ +│ 确认弹窗简化: │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 重新生成视频 │ │ +│ ├─────────────────────────────────────────┤ │ +│ │ 将重新生成分镜 3 的视频 │ │ +│ │ 预计耗时:约 3-5 分钟 │ │ +│ │ │ │ +│ │ ⚠️ 生成过程中请勿关闭应用 │ │ +│ │ │ │ +│ │ [取消] [确定] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ 完成后:自动选中该分镜并播放 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 三、异常流程 + +### 3.1 用户尝试关闭应用 + +``` +用户点击关闭窗口(或 Cmd+Q / Alt+F4) + │ + ▼ +┌─────────────────────────────────────────┐ +│ ⚠️ 确认关闭 │ +├─────────────────────────────────────────┤ +│ │ +│ 视频生成任务仍在进行中 │ +│ │ +│ 如果选择关闭: │ +│ - 任务将在后台继续运行 │ +│ - 生成完成后会推送系统通知 │ +│ - 下次打开应用可查看结果 │ +│ │ +│ [取消] [最小化到托盘] [关闭应用] │ +│ │ +└─────────────────────────────────────────┘ +``` + +### 3.2 应用崩溃/强制退出后恢复 + +``` +用户重新打开应用 + │ + ▼ +┌─────────────────────────────────────────┐ +│ 📋 恢复未完成任务 │ +├─────────────────────────────────────────┤ +│ │ +│ 检测到上次有未完成的视频生成任务 │ +│ │ +│ 项目:厨房改造方案 │ +│ 进度:已完成 5/8 个分镜 │ +│ 状态:仍在后台处理中 │ +│ │ +│ [查看进度] [我知道了] │ +│ │ +└─────────────────────────────────────────┘ + +点击【查看进度】: +- 跳转到视频生成页面 +- 自动恢复进度弹窗显示 +- 继续监听 SSE/轮询 +``` + +### 3.3 生成失败 + +``` +┌─────────────────────────────────────────┐ +│ ❌ 生成失败 │ +├─────────────────────────────────────────┤ +│ │ +│ 视频生成过程中发生错误 │ +│ │ +│ 错误信息:Kling API 超时 │ +│ │ +│ 已生成的视频已保存 │ +│ 失败的分镜:分镜3、分镜7 │ +│ │ +│ [返回查看] [重试失败项] │ +│ │ +└─────────────────────────────────────────┘ + +点击【重试失败项】: +- 只重新生成失败的那几个分镜 +- 复用现有参数 +``` + +### 3.4 网络断开 + +``` +SSE 连接断开 + │ + ▼ +状态栏显示:"网络异常,正在重连...(1/3)" + │ + ▼ +自动重连 SSE(最多 3 次) + │ + ├─► 重连成功:继续接收进度 + │ + └─► 重连失败:切换到轮询模式 + │ + ▼ + 每 5 秒轮询一次状态 + │ + ▼ + 网络恢复后:自动切回 SSE +``` + +## 四、本地状态管理 + +```typescript +// localStorage: meijiaka_generation_state +interface GenerationState { + // 任务标识 + jobId: string; + projectId: string; + + // 任务状态 + status: 'pending' | 'generating' | 'completed' | 'failed'; + + // 任务信息(用于恢复显示) + shots: Array<{ + id: string; + type: 'segment' | 'empty_shot'; + }>; + totalShots: number; + + // 时间戳 + startedAt: number; + lastUpdatedAt: number; + + // 结果(完成后填写) + results?: Array<{ + shotId: string; + status: 'completed' | 'failed'; + videoPath?: string; + errorMessage?: string; + }>; + + // 错误信息 + errorMessage?: string; +} +``` + +## 五、状态流转图 + +``` + ┌─────────────┐ + │ IDLE │ + └──────┬──────┘ + │ 点击生成 + ▼ + ┌─────────────┐ + ┌───────────────►│ CONFIRM │◄───────────────┐ + │ │ 确认弹窗 │ │ + │ └──────┬──────┘ │ + │ 取消 │ 确认 │ + │ ▼ │ + │ ┌─────────────┐ │ + │ │ GENERATING │────────────────┤ + │ │ 生成中 │ 应用崩溃/关闭 │ + │ └──────┬──────┘ │ + │ │ │ + │ ┌────────────┼────────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ + │ │SUCCESS │ │ FAILED │ │ TIMEOUT │ │ + │ └────┬────┘ └────┬────┘ └────┬────┘ │ + │ │ │ │ │ + │ ▼ └────────────┘ │ + │ ┌─────────┐ │ │ + └───┤ RESULT │◄──────────────┘ │ + │ 结果弹窗 │ │ + └────┬────┘ │ + │ │ + ▼ │ + ┌─────────┐ │ + │ IDLE │───────────────────────────────┘ + └─────────┘ 下次启动检测恢复 +``` + +## 六、关键决策点 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 生成中能否关闭应用 | ✅ 可以,但提示后台继续 | 用户有急事时需要关闭 | +| 生成中能否切换项目 | ❌ 不能 | 避免状态混乱 | +| 生成中能否修改脚本 | ❌ 不能 | 避免参数不一致 | +| 失败后能否重试 | ✅ 可以,只重试失败的 | 减少重复等待 | +| 是否需要系统通知 | ✅ 需要(第二阶段) | 用户最小化后能感知完成 | diff --git a/docs/volcengine-video-caption-api.md b/docs/volcengine-video-caption-api.md new file mode 100644 index 0000000..6e32f69 --- /dev/null +++ b/docs/volcengine-video-caption-api.md @@ -0,0 +1,201 @@ +# 火山引擎音视频字幕 API 开发文档 + +> 更新日期: 2026-04-09 +> 官方文档: https://www.volcengine.com/docs/6561/80907 + +--- + +## 产品简介 + +火山引擎音视频字幕服务提供两种能力: + +1. **音视频字幕生成** - 自动识别音频中的语音/歌词,生成带时间轴的字幕 +2. **自动字幕打轴** - 为已有字幕文本自动配上时间轴 + +--- + +## 基础信息 + +| 项目 | 内容 | +|------|------| +| 基础 URL | `https://openspeech.bytedance.com/api/v1/vc` | +| 鉴权 Header | `Authorization: Bearer; {token}` | +| 文件限制 | ≤200MB, 支持 WAV/M4A/MP3/MP4/MOV/OGG | + +--- + +## API 接口 + +### 1. 音视频字幕生成 + +#### 提交任务 +```http +POST /submit?appid={appid}&language=zh-CN&use_punc=True +Content-Type: application/json +Authorization: Bearer; {token} + +{"url": "https://example.com/audio.mp3"} +``` + +**关键参数:** +- `language` - 语言: `zh-CN`, `en-US`, `ja-JP`, `ko-KR`, `es-MX`, `ru-RU`, `fr-FR`, `yue`, `wuu`, `nan`, `ug` +- `caption_type` - 识别类型: `auto`(默认), `speech`, `singing` +- `use_punc` - 自动标点: `True`, `False` +- `use_itn` - 数字转换: `True`(中文数字转阿拉伯数字) +- `words_per_line` - 每行字数, 默认 46 +- `max_lines` - 每屏行数, 默认 1 + +#### 查询结果 +```http +GET /query?appid={appid}&id={task_id}&blocking=1 +Authorization: Bearer; {token} +``` + +**响应:** +```json +{ + "code": 0, + "message": "Success", + "duration": 5.32, + "utterances": [ + { + "text": "识别文本", + "start_time": 0, + "end_time": 3197, + "words": [ + {"text": "单字", "start_time": 0, "end_time": 208} + ] + } + ] +} +``` + +--- + +### 2. 自动字幕打轴 + +#### 提交任务 +```http +POST /ata/submit?appid={appid}&caption_type=speech +Content-Type: application/json +Authorization: Bearer; {token} + +{ + "url": "https://example.com/audio.mp3", + "audio_text": "这是要被打轴的字幕文本" +} +``` + +**参数:** +- `caption_type` - `speech`(说话) 或 `singing`(歌词) +- `sta_punc_mode` - 标点模式: `1`(省略句末标点), `2`(空格代替), `3`(保留完整标点) + +#### 查询结果 +```http +GET /ata/query?appid={appid}&id={task_id}&blocking=1 +Authorization: Bearer; {token} +``` + +--- + +## 错误码 + +| 码 | 含义 | 处理 | +|----|------|------| +| 0 | 成功 | - | +| 2000 | 处理中 | 继续轮询 | +| 1001 | 参数无效 | 检查必填参数 | +| 1002 | 无权限 | 检查 token | +| 1003 | 超频 | 降低调用频率 | +| 1010 | 音频过长 | 缩短音频 | +| 1011 | 音频过大 | 压缩音频(<200MB) | +| 1012 | 格式无效 | 检查音频格式 | +| 1013 | 音频静音 | 检查音频内容 | + +--- + +## Python 代码示例 + +```python +import requests +import time + +TOKEN = "your_token" +APPID = "your_appid" +BASE_URL = "https://openspeech.bytedance.com/api/v1/vc" + +def submit(audio_url, language="zh-CN", use_punc=True): + """提交字幕生成任务""" + resp = requests.post( + f"{BASE_URL}/submit", + params={"appid": APPID, "language": language, "use_punc": str(use_punc)}, + json={"url": audio_url}, + headers={"Authorization": f"Bearer; {TOKEN}"} + ) + return resp.json()["id"] + +def query(task_id): + """查询任务结果""" + resp = requests.get( + f"{BASE_URL}/query", + params={"appid": APPID, "id": task_id, "blocking": "1"}, + headers={"Authorization": f"Bearer; {TOKEN}"} + ) + return resp.json() + +def generate_caption(audio_url, language="zh-CN"): + """完整流程: 提交->轮询->返回结果""" + task_id = submit(audio_url, language) + + for _ in range(60): # 最多轮询60秒 + result = query(task_id) + if result["code"] == 0: + return result["utterances"] + elif result["code"] != 2000: + raise Exception(f"Task failed: {result['message']}") + time.sleep(1) + + raise Exception("Timeout") + +def to_srt(utterances): + """转换为 SRT 字幕格式""" + def ms_to_time(ms): + h = ms // 3600000 + m = (ms % 3600000) // 60000 + s = (ms % 60000) // 1000 + ms = ms % 1000 + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + lines = [] + for i, u in enumerate(utterances, 1): + lines.append(f"{i}") + lines.append(f"{ms_to_time(u['start_time'])} --> {ms_to_time(u['end_time'])}") + lines.append(u['text']) + lines.append("") + return "\n".join(lines) + +# 使用示例 +if __name__ == "__main__": + utterances = generate_caption("https://example.com/audio.mp3") + srt_content = to_srt(utterances) + print(srt_content) +``` + +--- + +## cURL 示例 + +```bash +# 1. 提交任务 +TASK_ID=$(curl -s -X POST \ + -H "Authorization: Bearer; ${TOKEN}" \ + -H "content-type: application/json" \ + -d '{"url": "'${AUDIO_URL}'"}' \ + "https://openspeech.bytedance.com/api/v1/vc/submit?appid=${APPID}&language=zh-CN" \ + | jq -r '.id') + +# 2. 查询结果 +curl -s -X GET \ + -H "Authorization: Bearer; ${TOKEN}" \ + "https://openspeech.bytedance.com/api/v1/vc/query?appid=${APPID}&id=${TASK_ID}&blocking=1" +``` diff --git a/python-api/.env.example b/python-api/.env.example new file mode 100644 index 0000000..f86179c --- /dev/null +++ b/python-api/.env.example @@ -0,0 +1,72 @@ +# 美家卡智影 API - 环境变量配置示例 +# ================================ +# 复制此文件为 .env 并填写实际值 + +# === 基础配置 === +APP_NAME=美家卡智影 API +APP_VERSION=0.1.0 +DEBUG=true +ENV=development +HOST=0.0.0.0 +PORT=8000 + +# === 数据库配置 === +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka + +# === Redis 配置 === +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +# REDIS_PASSWORD= # 如无密码请留空或注释 + +# === JWT 安全配置 === +# 生产环境必须修改为强随机密钥 +SECRET_KEY=your-secret-key-here-change-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=10080 +ALGORITHM=HS256 + +# === CORS 配置 === +CORS_ORIGINS=http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080 + +# === AI 平台配置 === + +# 火山方舟(必需) +VOLCENGINE_API_KEY=your-volcengine-api-key +VOLCENGINE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 + +# 火山字幕服务(必需) +VOLCENGINE_CAPTION_APPID=your-caption-appid +VOLCENGINE_CAPTION_TOKEN=your-caption-token + +# 可灵 AI(必需,用于视频生成) +KLINGAI_ACCESS_KEY=your-kling-access-key +KLINGAI_SECRET_KEY=your-kling-secret-key + +# OpenAI(可选) +# OPENAI_API_KEY=sk-your-openai-key +# OPENAI_BASE_URL=https://api.openai.com/v1 + +# 文心一言(可选) +# WENXIN_API_KEY=your-wenxin-key +# WENXIN_SECRET_KEY=your-wenxin-secret + +# 通义千问(可选) +# QIANWEN_API_KEY=your-qianwen-key + +# === 七牛云存储(必需,用于空镜图片上传)=== +QINIU_ACCESS_KEY=your-qiniu-access-key +QINIU_SECRET_KEY=your-qiniu-secret-key +QINIU_VIDEO_BUCKET=media-liche +QINIU_VIDEO_DOMAIN=media.liche.cn +QINIU_IMAGE_BUCKET=img-liche +QINIU_IMAGE_DOMAIN=img.liche.cn + +# === 其他服务 === + +# AnyToCopy 文案提取(可选) +ANYTOCOPY_API_KEY=your-anytocopy-api-key +ANYTOCOPY_API_SECRET=your-anytocopy-secret +ANYTOCOPY_BASE_URL=https://api.anytocopy.com/vip/open-api/v1 + +# === 日志配置 === +LOG_LEVEL=INFO diff --git a/python-api/.gitignore b/python-api/.gitignore new file mode 100644 index 0000000..578177f --- /dev/null +++ b/python-api/.gitignore @@ -0,0 +1,75 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Environment variables +.env +.env.local +.env.*.local + +# Database +*.db +*.sqlite3 + +# Logs +*.log +logs/ + +# Test coverage +htmlcov/ +.coverage +.pytest_cache/ +.tox/ + +# Alembic 迁移(保留脚本,忽略临时文件) +alembic/versions/*.pyc + +# Celery +celerybeat-schedule + +# Redis +dump.rdb + +# Docker +.dockerignore + +# Local development +local/ +temp/ +tmp/ + +# Data files +data/ + diff --git a/python-api/.pre-commit-config.yaml b/python-api/.pre-commit-config.yaml new file mode 100644 index 0000000..e86e12c --- /dev/null +++ b/python-api/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +# 美家卡智影 - Git 钩子配置 +# 安装: pre-commit install +# 手动运行: pre-commit run --all-files + +repos: + # 代码格式化 + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3.13 + + # 代码检查 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff + args: [--fix] + + # TODO: 修复历史遗留类型错误后重新启用 + # 类型检查(暂时禁用) + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.14.0 + # hooks: + # - id: mypy + # additional_dependencies: [types-PyYAML] + + # 安全扫描(暂时禁用) + # - repo: https://github.com/PyCQA/bandit + # rev: 1.8.0 + # hooks: + # - id: bandit + # args: ["-c", "pyproject.toml"] + # additional_dependencies: ["bandit[toml]"] + + # 依赖锁定文件同步检查 + - repo: local + hooks: + - id: uv-lock-check + name: Check uv lock file is up-to-date + entry: bash -c 'uv pip compile pyproject.toml -o requirements.lock --locked' + language: system + files: ^(pyproject\.toml|requirements\.lock)$ + pass_filenames: false diff --git a/python-api/.python-version b/python-api/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/python-api/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/python-api/.qiniu_pythonsdk_hostscache.json b/python-api/.qiniu_pythonsdk_hostscache.json new file mode 100644 index 0000000..6cd2d8f --- /dev/null +++ b/python-api/.qiniu_pythonsdk_hostscache.json @@ -0,0 +1 @@ +{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776740815}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}} \ No newline at end of file diff --git a/python-api/Dockerfile b/python-api/Dockerfile new file mode 100644 index 0000000..cce032c --- /dev/null +++ b/python-api/Dockerfile @@ -0,0 +1,44 @@ +# 美家卡智影 API - Docker 镜像 (使用 uv 优化) +# =========================================== + +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +# 设置 uv 环境变量 +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +# 先复制锁定文件,利用 Docker 缓存层 +COPY requirements.lock pyproject.toml ./ + +# 创建虚拟环境并安装依赖(利用 uv 的速度优势) +RUN uv venv /opt/venv && \ + uv pip sync --python /opt/venv/bin/python requirements.lock + +# 复制应用代码 +COPY app/ ./app/ + +# 安装应用本身(不安装 dev 依赖) +RUN uv pip install --python /opt/venv/bin/python --no-deps -e . + +# ===== 生产镜像 ===== +FROM python:3.13-slim AS production + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" + +# 从 builder 复制虚拟环境 +COPY --from=builder /opt/venv /opt/venv + +WORKDIR /app + +# 复制应用代码 +COPY app/ ./app/ +COPY pyproject.toml . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/python-api/Makefile b/python-api/Makefile new file mode 100644 index 0000000..9dfd4b3 --- /dev/null +++ b/python-api/Makefile @@ -0,0 +1,140 @@ +# 美家卡智影 API - 常用命令 +# ========================== + +.PHONY: help install dev install-hooks update-lock lint format test security clean docker + +help: ## 显示帮助信息 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# ========== 依赖管理 ========== + +install: ## 安装生产依赖(使用 lock 文件) + uv pip sync requirements.lock + +dev: ## 安装开发依赖(包含 dev extras) + uv pip install -e ".[dev]" + pre-commit install + +install-hooks: ## 安装 Git pre-commit 钩子 + pre-commit install + +update-lock: ## 更新 requirements.lock(修改 pyproject.toml 后执行) + uv pip compile pyproject.toml -o requirements.lock --upgrade + +update-lock-no-upgrade: ## 重新生成 lock 文件(不升级版本) + uv pip compile pyproject.toml -o requirements.lock + +# ========== 代码质量 ========== + +lint: ## 运行代码检查 (ruff + mypy) + ruff check app/ + mypy app/ + +format: ## 格式化代码 (black + ruff) + black app/ + ruff check --fix app/ + +format-check: ## 检查代码格式(不修改) + black --check app/ + ruff check app/ + +# ========== 测试 ========== + +test: ## 运行测试 + pytest -v + +test-cov: ## 运行测试并生成覆盖率报告 + pytest --cov=app --cov-report=html --cov-report=term + +# ========== 安全扫描 ========== + +security: ## 运行安全扫描 (bandit + pip-audit) + @echo "🔍 运行 Bandit 安全扫描..." + bandit -r app/ -c pyproject.toml + @echo "🔍 运行依赖漏洞扫描..." + pip-audit + +# ========== 开发服务器 ========== + +run: ## 启动开发服务器 + uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +scheduler: ## 启动 Async Engine Scheduler + python -m app.scheduler.main + +# ========== Docker ========== + +docker: ## 构建 Docker 镜像 + docker build -t meijiaka-api:latest . + +docker-run: ## 使用 Docker Compose 启动全部服务 + docker-compose up -d + +docker-logs: ## 查看 Docker 日志 + docker-compose logs -f + +docker-down: ## 停止 Docker 服务 + docker-compose down + +# ========== 清理 ========== + +clean: ## 清理缓存文件 + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true + rm -rf htmlcov/ .coverage 2>/dev/null || true + +# ========== 语义层防护网 ========== + +lint-semantic: ## 语义层禁词检查(防止供应商术语泄漏到业务层) + @echo "🔍 检查 Layer 3+ 是否泄漏供应商术语..." + @# API 层(除 klingai Provider 代理)禁止 element_id 作为字段/参数名 + @errs=$$(grep -rn 'element_id' app/api --include='*.py' \ + | grep -v 'klingai.py' \ + | grep -v 'provider_element_id' \ + | grep -v '__pycache__' \ + | grep -v '#' \ + | grep -v '".*element_id.*"' \ + | grep -v "'.*element_id.*'"); \ + if [ -n "$$errs" ]; then \ + echo "$$errs"; \ + echo "❌ API 层发现 element_id(应使用 provider_element_id 或 human_id)"; \ + exit 1; \ + fi + @# Scheduler 层禁止 task_id 作为内部变量/Redis key(读取 Provider 返回除外) + @errs=$$(grep -rn '\btask_id\b' app/scheduler --include='*.py' \ + | grep -v 'job_id' \ + | grep -v '__pycache__' \ + | grep -v '\.get("task_id")' \ + | grep -v 'result.get("task_id")' \ + | grep -v 'task_type' \ + | grep -v '"task_id"' \ + | grep -v "'task_id'"); \ + if [ -n "$$errs" ]; then \ + echo "$$errs"; \ + echo "❌ Scheduler 层发现 task_id(应使用 job_id)"; \ + exit 1; \ + fi + @# 全局禁止 kling_task_id 作为持久化字段 + @errs=$$(grep -rn 'kling_task_id' app --include='*.py' \ + | grep -v '__pycache__' \ + | grep -v 'providers/klingai'); \ + if [ -n "$$errs" ]; then \ + echo "$$errs"; \ + echo "❌ 发现 kling_task_id(应使用 provider_task_id)"; \ + exit 1; \ + fi + @# Scheduler 层 Redis key 必须使用 job: 而非 task: + @errs=$$(grep -rn 'redis.*task:' app/scheduler --include='*.py' \ + | grep -v '__pycache__'); \ + if [ -n "$$errs" ]; then \ + echo "$$errs"; \ + echo "❌ Scheduler Redis key 使用 task:(应使用 job:)"; \ + exit 1; \ + fi + @echo "✅ 语义层检查通过" + +# ========== CI 检查 ========== + +ci: format-check lint lint-semantic test security ## 运行所有 CI 检查 diff --git a/python-api/README.md b/python-api/README.md new file mode 100644 index 0000000..324c807 --- /dev/null +++ b/python-api/README.md @@ -0,0 +1,166 @@ +# 美家卡智影 API + +美家卡智影后端服务 - 基于 FastAPI + PostgreSQL + Redis 的 AI 视频创作 API。 + +## 技术栈 + +| 组件 | 技术 | 版本 | +|------|------|------| +| Web 框架 | FastAPI | ^0.110.0 | +| 数据库 | PostgreSQL | 15+ | +| ORM | SQLAlchemy | 2.0+ (异步) | +| 缓存/状态 | Redis | 7.x | +| 异步调度 | Async Engine (Slot Scheduler) | Python asyncio | +| 部署 | Docker + Docker Compose | - | + +## 快速开始 + +### 1. 环境准备 + +确保已安装: +- Python 3.11+ +- Docker & Docker Compose(推荐) +- 或本地 PostgreSQL + Redis + +### 2. 使用 Docker Compose 启动(推荐) + +```bash +# 1. 克隆项目后进入目录 +cd python-api + +# 2. 复制环境变量配置 +cp .env.example .env + +# 3. 启动所有服务 +docker-compose up -d + +# 4. 查看日志 +docker-compose logs -f api + +# 5. 服务地址 +# API: http://localhost:8080 +# 文档: http://localhost:8080/docs +``` + +### 3. 本地开发 + +```bash +# 1. 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 2. 安装依赖 +pip install -e ".[dev]" + +# 3. 配置环境变量 +cp .env.example .env +# 编辑 .env,修改数据库连接等配置 + +# 4. 启动 PostgreSQL 和 Redis(Docker) +docker-compose up -d db redis + +# 5. 启动开发服务器 +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 7. 启动 Async Engine Scheduler(另开终端) +python -m app.scheduler.main +``` + +## 项目结构 + +``` +python-api/ +├── app/ # 主应用代码 +│ ├── api/v1/ # API 路由 +│ ├── core/ # 核心工具(安全、异常) +│ ├── db/ # 数据库配置 +│ ├── models/ # SQLAlchemy 模型 +│ ├── schemas/ # Pydantic Schema +│ ├── services/ # 业务逻辑 +│ ├── scheduler/ # Async Engine 异步任务调度 +│ ├── ai/ # AI 模型相关 +│ ├── utils/ # 工具函数 +│ ├── config.py # 配置管理 +│ └── main.py # FastAPI 入口 +├── docker-compose.yml # Docker 编排 +├── Dockerfile # Docker 镜像 +├── pyproject.toml # 项目依赖 +└── README.md # 本文档 +``` + +## 数据模型 + +### 核心实体 + +- **User** - 用户/设备(设备 ID + JWT 认证) +- **Project** - 视频创作项目 +- **ScriptSegment** - 脚本分镜 +- **MediaAsset** - 媒体元数据(音频/视频/封面) +- **TaskQueue** - 异步任务队列 + +## API 路由 + +### 已实现 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/auth/login` | 设备登录/注册 | +| GET | `/api/v1/auth/me` | 获取当前用户 | +| GET | `/api/v1/system/health` | 健康检查 | +| GET | `/api/v1/system/version` | 版本信息 | + +### 待实现(M2-M5) + +- `/api/v1/script/*` - 脚本生成(SSE 流式) +- `/api/v1/voice/*` - 语音合成(TTS) +- `/api/v1/video/*` - 数字人视频(异步任务) +- `/api/v1/project/*` - 项目云同步 +- `/api/v1/parser/*` - 视频链接解析(预留) + +## 环境变量 + +见 `.env.example`,主要配置项: + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `DATABASE_URL` | PostgreSQL 连接字符串 | `postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka` | +| `REDIS_URL` | Redis 连接字符串 | `redis://localhost:6379/0` | +| `SECRET_KEY` | JWT 签名密钥 | 必须修改 | +| `OPENAI_API_KEY` | OpenAI API Key | - | +| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:1420` | + +## 开发规范 + +### 代码风格 + +```bash +# 格式化 +black app/ + +# 检查 +ruff check app/ +mypy app/ + +# 测试 +pytest +``` + +### 提交规范 + +- `feat:` 新功能 +- `fix:` 修复 +- `docs:` 文档 +- `refactor:` 重构 +- `test:` 测试 + +## 与前端集成 + +Tauri 前端默认连接 `http://127.0.0.1:8080/api/v1`。 + +云端部署后: +1. 修改前端 `src/api/client.ts` 中的 `PYTHON_API_BASE_URL` +2. 更新 `tauri.conf.json` CSP 配置,添加云端域名到 `connect-src` + +## 许可 + +MIT diff --git a/python-api/alembic.ini b/python-api/alembic.ini new file mode 100644 index 0000000..f7f8540 --- /dev/null +++ b/python-api/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# 数据库 URL 从环境变量读取,在 env.py 中设置 +# sqlalchemy.url = postgresql://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/python-api/alembic/README b/python-api/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/python-api/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/python-api/alembic/env.py b/python-api/alembic/env.py new file mode 100644 index 0000000..e6395f0 --- /dev/null +++ b/python-api/alembic/env.py @@ -0,0 +1,77 @@ +""" +Alembic 环境配置 - PostgreSQL +""" + +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# 加载环境变量 +from dotenv import load_dotenv + +load_dotenv() + +# 导入模型 +from app.db.session import Base +from app.models.avatar import Avatar # noqa +from app.models.model_usage import ModelUsageLog # noqa +from app.models.user import User # noqa + +# this is the Alembic Config object +config = context.config + +# 从环境变量读取数据库 URL +database_url = os.getenv("DATABASE_URL") +if database_url: + # 将 asyncpg 转换为 psycopg2 用于 alembic (同步) + sync_database_url = database_url.replace("+asyncpg", "") + config.set_main_option("sqlalchemy.url", sync_database_url) + +# 设置日志 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 模型元数据 +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/python-api/alembic/script.py.mako b/python-api/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/python-api/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/python-api/alembic/versions/451756e6a43e_rename_avatar_vendor_fields_add_provider.py b/python-api/alembic/versions/451756e6a43e_rename_avatar_vendor_fields_add_provider.py new file mode 100644 index 0000000..a46b67b --- /dev/null +++ b/python-api/alembic/versions/451756e6a43e_rename_avatar_vendor_fields_add_provider.py @@ -0,0 +1,106 @@ +"""rename_avatar_vendor_fields_add_provider + +Revision ID: 451756e6a43e +Revises: d4bd9ad91607 +Create Date: 2026-04-17 12:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "451756e6a43e" +down_revision: str | Sequence[str] | None = "d4bd9ad91607" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Add provider column with default "kling" + op.add_column( + "avatars", + sa.Column( + "provider", + sa.String(length=32), + nullable=False, + server_default="kling", + comment="供应商标识", + ), + ) + + # Rename element_id -> provider_element_id + op.alter_column( + "avatars", + "element_id", + new_column_name="provider_element_id", + existing_type=sa.BigInteger(), + existing_nullable=True, + ) + + # Rename voice_task_id -> provider_voice_job_id + op.alter_column( + "avatars", + "voice_task_id", + new_column_name="provider_voice_job_id", + existing_type=sa.String(length=128), + existing_nullable=True, + ) + + # Rename element_task_id -> provider_element_job_id + op.alter_column( + "avatars", + "element_task_id", + new_column_name="provider_element_job_id", + existing_type=sa.String(length=128), + existing_nullable=True, + ) + + # Rename indexes + op.drop_index("ix_avatars_voice_task_id", table_name="avatars") + op.drop_index("ix_avatars_element_task_id", table_name="avatars") + op.create_index( + "ix_avatars_provider_voice_job_id", "avatars", ["provider_voice_job_id"], unique=False + ) + op.create_index( + "ix_avatars_provider_element_job_id", "avatars", ["provider_element_job_id"], unique=False + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # Rename indexes back + op.drop_index("ix_avatars_provider_element_job_id", table_name="avatars") + op.drop_index("ix_avatars_provider_voice_job_id", table_name="avatars") + op.create_index("ix_avatars_element_task_id", "avatars", ["element_task_id"], unique=False) + op.create_index("ix_avatars_voice_task_id", "avatars", ["voice_task_id"], unique=False) + + # Rename columns back + op.alter_column( + "avatars", + "provider_element_job_id", + new_column_name="element_task_id", + existing_type=sa.String(length=128), + existing_nullable=True, + ) + op.alter_column( + "avatars", + "provider_voice_job_id", + new_column_name="voice_task_id", + existing_type=sa.String(length=128), + existing_nullable=True, + ) + op.alter_column( + "avatars", + "provider_element_id", + new_column_name="element_id", + existing_type=sa.BigInteger(), + existing_nullable=True, + ) + + # Drop provider column + op.drop_column("avatars", "provider") diff --git a/python-api/alembic/versions/d4bd9ad91607_add_avatars_table.py b/python-api/alembic/versions/d4bd9ad91607_add_avatars_table.py new file mode 100644 index 0000000..79ece00 --- /dev/null +++ b/python-api/alembic/versions/d4bd9ad91607_add_avatars_table.py @@ -0,0 +1,55 @@ +"""add avatars table + +Revision ID: d4bd9ad91607 +Revises: fb1be66e804a +Create Date: 2026-04-06 21:51:36.225361 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4bd9ad91607' +down_revision: Union[str, Sequence[str], None] = 'fb1be66e804a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('avatars', + sa.Column('id', sa.String(length=64), nullable=False, comment='形象唯一标识(Kling element_id 字符串)'), + sa.Column('user_id', sa.String(length=36), nullable=False, comment='关联用户 ID'), + sa.Column('name', sa.String(length=64), nullable=False, comment='形象展示名称'), + sa.Column('voice_id', sa.String(length=64), nullable=True, comment='Kling 自定义音色 ID'), + sa.Column('element_id', sa.BigInteger(), nullable=True, comment='Kling 主体 ID'), + sa.Column('voice_task_id', sa.String(length=128), nullable=True, comment='Kling 自定义音色任务 ID'), + sa.Column('element_task_id', sa.String(length=128), nullable=True, comment='Kling 主体创建任务 ID'), + sa.Column('video_url', sa.Text(), nullable=False, comment='原始人物视频 URL'), + sa.Column('trial_url', sa.Text(), nullable=True, comment='音色试听音频 URL'), + sa.Column('status', sa.String(length=32), nullable=False, comment='状态: pending/voice_processing/voice_failed/element_processing/element_failed/succeed/timeout'), + sa.Column('fail_reason', sa.Text(), nullable=True, comment='失败原因(中文可读)'), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='软删除时间,NULL 表示未删除'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='记录创建时间'), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, comment='记录更新时间'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_avatars_element_task_id'), 'avatars', ['element_task_id'], unique=False) + op.create_index(op.f('ix_avatars_user_id'), 'avatars', ['user_id'], unique=False) + op.create_index(op.f('ix_avatars_voice_task_id'), 'avatars', ['voice_task_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_avatars_voice_task_id'), table_name='avatars') + op.drop_index(op.f('ix_avatars_user_id'), table_name='avatars') + op.drop_index(op.f('ix_avatars_element_task_id'), table_name='avatars') + op.drop_table('avatars') + # ### end Alembic commands ### diff --git a/python-api/alembic/versions/fb1be66e804a_replace_device_id_with_mobile_in_users_.py b/python-api/alembic/versions/fb1be66e804a_replace_device_id_with_mobile_in_users_.py new file mode 100644 index 0000000..7a018d8 --- /dev/null +++ b/python-api/alembic/versions/fb1be66e804a_replace_device_id_with_mobile_in_users_.py @@ -0,0 +1,38 @@ +"""replace device_id with mobile in users table + +Revision ID: fb1be66e804a +Revises: +Create Date: 2026-04-03 10:22:30.465704 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fb1be66e804a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('mobile', sa.String(length=20), nullable=False, comment='手机号')) + op.drop_index(op.f('ix_users_device_id'), table_name='users') + op.create_index(op.f('ix_users_mobile'), 'users', ['mobile'], unique=True) + op.drop_column('users', 'device_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('device_id', sa.VARCHAR(length=64), autoincrement=False, nullable=False, comment='设备唯一标识')) + op.drop_index(op.f('ix_users_mobile'), table_name='users') + op.create_index(op.f('ix_users_device_id'), 'users', ['device_id'], unique=True) + op.drop_column('users', 'mobile') + # ### end Alembic commands ### diff --git a/python-api/app/__init__.py b/python-api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/ai/__init__.py b/python-api/app/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/ai/model_router.py b/python-api/app/ai/model_router.py new file mode 100644 index 0000000..d3de613 --- /dev/null +++ b/python-api/app/ai/model_router.py @@ -0,0 +1,417 @@ +""" +AI 模型路由 V2 - 基于文件配置 +================================= + +从 YAML 配置文件加载平台/模型配置,支持热重载。 +""" + +import asyncio +import logging +from collections.abc import AsyncIterator + +from app.ai.providers.base import GenerationResult, ModelHealth, ProviderError +from app.ai.providers.generic_llm_provider import MockProvider +from app.ai.providers.klingai_provider import KlingAIProvider +from app.ai.providers.volcengine_provider import VolcengineProvider +from app.config import get_settings +from app.core.config_loader import AIModelConfigLoader, get_config_loader + +logger = logging.getLogger(__name__) + + +class PlatformInstance: + """平台实例包装器""" + + def __init__(self, config: dict): + self.config = config + self.provider = self._create_provider() + + def _create_provider(self): + """根据平台类型创建 Provider + + API Key 从 Settings 读取(符合配置规范) + """ + provider_type = self.config.get("provider", "mock") + settings = get_settings() + + if provider_type == "volcengine": + # 从 Settings 读取 API Key + api_key = settings.VOLCENGINE_API_KEY + if not api_key: + raise ProviderError( + "Volcengine API Key 未配置,请在 .env 中设置 VOLCENGINE_API_KEY" + ) + return VolcengineProvider( + api_key=api_key, + base_url=self.config.get("base_url") or settings.VOLCENGINE_BASE_URL, + ) + elif provider_type == "klingai": + # 从 Settings 读取 AK/SK + access_key = settings.KLINGAI_ACCESS_KEY + secret_key = settings.KLINGAI_SECRET_KEY + if not access_key or not secret_key: + raise ProviderError( + "KlingAI Access/Secret Key 未配置,请在 .env 中设置 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY" + ) + return KlingAIProvider( + config={ + "access_key": access_key, + "secret_key": secret_key, + "base_url": self.config.get("base_url"), + } + ) + elif provider_type == "mock": + return MockProvider() + else: + raise ProviderError(f"不支持的 Provider 类型: {provider_type}") + + async def generate( + self, model_name: str, prompt: str, **kwargs + ) -> GenerationResult: + """调用生成""" + return await self.provider.generate(prompt=prompt, model=model_name, **kwargs) + + async def generate_stream( + self, model_name: str, prompt: str, **kwargs + ) -> AsyncIterator[str]: + """流式生成""" + async for chunk in self.provider.generate_stream( + prompt=prompt, model=model_name, **kwargs + ): + yield chunk + + async def health_check(self, model_name: str | None = None) -> ModelHealth: + """健康检查""" + return await self.provider.health_check(model_name) + + +class ModelRouter: + """ + 模型路由 V2 - 基于文件配置 + + 支持: + - 从 YAML 文件加载配置 + - 多平台配置 + - 每平台多模型 + - 模型自动选择 + - 故障降级 + - 配置热重载 + """ + + def __init__(self): + self.platforms: dict[str, PlatformInstance] = {} + self._config_loader: AIModelConfigLoader | None = None + self._initialized = False + + async def initialize(self, db_session=None): + """初始化路由(db_session 参数保留兼容性,实际不使用)""" + if self._initialized: + return + + # 从文件配置加载 + self._config_loader = get_config_loader() + self._load_from_config() + + self._initialized = True + logger.info(f"ModelRouter 初始化完成: {len(self.platforms)} 平台") + + def _load_from_config(self): + """从配置文件加载平台和模型""" + self.platforms = {} + + # 加载平台 + for platform in self._config_loader.get_all_platforms(): + try: + # PlatformInstance 自动从 Settings 读取 API Key + self.platforms[platform.id] = PlatformInstance( + { + "id": platform.id, + "name": platform.name, + "provider": platform.provider, + "base_url": platform.base_url, + } + ) + logger.info(f"平台 {platform.id} 初始化成功") + except Exception as e: + logger.warning(f"平台 {platform.id} 初始化失败: {e}") + + # 加载模型到 Provider(用于模型名称映射) + volcengine_models = [] + for model in self._config_loader.get_enabled_models(): + if model.platform_id == "volcengine": + volcengine_models.append( + { + "id": model.id, + "model_name": model.model_name, + } + ) + + if volcengine_models: + VolcengineProvider.load_models_from_config(volcengine_models) + logger.info(f"已加载 {len(volcengine_models)} 个火山方舟模型到 Provider") + + def reload_config(self) -> bool: + """重新加载配置""" + if self._config_loader and self._config_loader.reload(): + self._load_from_config() + return True + return False + + def get_model_config(self, model_id: str) -> dict | None: + """获取模型配置""" + if self._config_loader: + model = self._config_loader.get_model(model_id) + if model: + return { + "id": model.id, + "platform_id": model.platform_id, + "model_name": model.model_name, + "display_name": model.display_name, + "capabilities": model.capabilities, + "default_params": model.default_params, + "cost_per_1k_input": model.cost_per_1k_input, + "cost_per_1k_output": model.cost_per_1k_output, + "max_tokens_limit": model.max_tokens_limit, + } + return None + + def list_models( + self, capability: str | None = None, platform_id: str | None = None + ) -> list[dict]: + """列出可用模型""" + models = [] + + if self._config_loader: + if capability: + config_models = self._config_loader.get_models_by_capability(capability) + elif platform_id: + config_models = self._config_loader.get_models_by_platform(platform_id) + else: + config_models = self._config_loader.get_enabled_models() + + for model in config_models: + models.append( + { + "id": model.id, + "platform_id": model.platform_id, + "model_name": model.model_name, + "display_name": model.display_name, + "capabilities": model.capabilities, + "default_params": model.default_params, + "cost_per_1k_input": model.cost_per_1k_input, + "cost_per_1k_output": model.cost_per_1k_output, + "max_tokens_limit": model.max_tokens_limit, + } + ) + + return models + + def list_platforms(self) -> list[dict]: + """列出所有平台""" + if self._config_loader: + return [ + { + "id": p.id, + "name": p.name, + "provider": p.provider, + } + for p in self._config_loader.get_all_platforms() + ] + return [] + + def select_model_for_task(self, task_type: str) -> str | None: + """根据任务类型选择最佳模型""" + # 先检查任务默认配置 + if self._config_loader: + default_model = self._config_loader.get_default_model_for_task(task_type) + if default_model: + model = self._config_loader.get_model(default_model) + if model and model.is_enabled: + return default_model + + # 按能力匹配 + candidates = self._config_loader.get_models_by_capability(task_type) + if candidates: + return candidates[0].id + + return None + + async def generate( + self, + prompt: str, + model_id: str | None = None, + task_type: str | None = None, + **kwargs, + ) -> GenerationResult: + """ + 生成文本 + + Args: + prompt: 提示词 + model_id: 指定模型 ID,None 则自动选择 + task_type: 任务类型(用于自动选模型) + """ + # 确定模型 + if model_id is None: + if task_type: + model_id = self.select_model_for_task(task_type) + if model_id is None: + # 使用第一个可用模型 + models = ( + self._config_loader.get_enabled_models() + if self._config_loader + else [] + ) + if models: + model_id = models[0].id + else: + raise ProviderError("没有可用的模型") + + if self._config_loader: + model = self._config_loader.get_model(model_id) + if not model: + raise ProviderError(f"模型不存在: {model_id}") + + platform = self.platforms.get(model.platform_id) + if not platform: + raise ProviderError(f"平台不存在: {model.platform_id}") + + # 合并默认参数 + params = {**model.default_params, **kwargs} + + # 调用生成 + try: + result = await platform.generate( + prompt=prompt, model_name=model.model_name, **params + ) + return result + + except Exception as e: + logger.error(f"模型 {model_id} 生成失败: {e}") + raise + + async def generate_stream_with_progress( + self, + prompt: str, + model_id: str | None = None, + task_type: str | None = None, + **kwargs, + ): + """ + 流式生成文本,带进度信息 + + Args: + prompt: 提示词 + model_id: 指定模型 ID + task_type: 任务类型 + **kwargs: 其他参数 + + Yields: + dict: 包含 type, content, total_chars 等字段 + """ + # 确定模型 + if model_id is None: + if task_type: + model_id = self.select_model_for_task(task_type) + if model_id is None: + models = ( + self._config_loader.get_enabled_models() + if self._config_loader + else [] + ) + if models: + model_id = models[0].id + else: + raise ProviderError("没有可用的模型") + + model = self._config_loader.get_model(model_id) if self._config_loader else None + if not model: + raise ProviderError(f"模型不存在: {model_id}") + + platform = self.platforms.get(model.platform_id) + if not platform: + raise ProviderError(f"平台不存在: {model.platform_id}") + + # 合并默认参数 + params = {**model.default_params, **kwargs} + + # 检查 provider 是否有 generate_stream_with_progress 方法 + provider = platform.provider + if hasattr(provider, "generate_stream_with_progress"): + async for chunk in provider.generate_stream_with_progress( + prompt=prompt, model=model.model_name, **params + ): + yield chunk + else: + # 降级到普通流式生成 + full_content = "" + async for content in provider.generate_stream( + prompt=prompt, model=model.model_name, **params + ): + full_content += content + yield { + "type": "chunk", + "content": content, + "total_chars": len(full_content), + } + + yield { + "type": "usage", + "prompt_tokens": 0, + "completion_tokens": 0, + } + + async def health_check(self, model_id: str | None = None) -> dict[str, ModelHealth]: + """检查模型健康状态""" + results = {} + + if model_id: + model = ( + self._config_loader.get_model(model_id) if self._config_loader else None + ) + if model: + platform = self.platforms.get(model.platform_id) + if platform: + results[model_id] = await platform.health_check(model.model_name) + else: + # 检查所有模型 + if self._config_loader: + for model in self._config_loader.get_enabled_models(): + platform = self.platforms.get(model.platform_id) + if platform: + try: + results[model.id] = await platform.health_check( + model.model_name + ) + except Exception as e: + results[model.id] = ModelHealth( + id=model.id, + name=model.display_name, + is_available=False, + response_time=0, + last_error=str(e), + ) + + return results + + +# 全局单例 +_model_router: ModelRouter | None = None +_init_lock = asyncio.Lock() + + +async def get_model_router(db_session=None) -> ModelRouter: + """获取 ModelRouter 单例(线程安全) + + 使用双重检查锁定模式确保并发安全。 + """ + global _model_router + if _model_router is None: + async with _init_lock: + # 双重检查,防止在获取锁期间其他协程已初始化 + if _model_router is None: + logger.info("Initializing ModelRouter singleton...") + _model_router = ModelRouter() + await _model_router.initialize(db_session) + logger.info("ModelRouter singleton initialized") + return _model_router diff --git a/python-api/app/ai/prompts/__init__.py b/python-api/app/ai/prompts/__init__.py new file mode 100644 index 0000000..5acf512 --- /dev/null +++ b/python-api/app/ai/prompts/__init__.py @@ -0,0 +1,46 @@ +""" +Prompt 模板系统 +================ + +家装行业 AI 视频脚本 Prompt 模板。 +所有 Prompt 存储在 txt 文件中,支持热更新。 + +使用示例: + from app.ai.prompts import load_script_system, load_script_user + + # 加载 System Prompt + system = load_script_system() + + # 加载并渲染 User Prompt + user = load_script_user( + topic="装修避坑", + duration=45, + script_type="干货型" + ) +""" + +from .loader import ( + SCRIPT_TYPES, + VIDEO_STYLES, + PolishPromptBuilder, + ScriptPromptBuilder, + load_polish_scene, + load_polish_voiceover, + load_prompt, + load_script_system, + load_script_user, + render_template, +) + +__all__ = [ + "load_prompt", + "render_template", + "load_script_system", + "load_script_user", + "load_polish_scene", + "load_polish_voiceover", + "ScriptPromptBuilder", + "PolishPromptBuilder", + "SCRIPT_TYPES", + "VIDEO_STYLES", +] diff --git a/python-api/app/ai/prompts/cover/cover.txt b/python-api/app/ai/prompts/cover/cover.txt new file mode 100644 index 0000000..d6d11e7 --- /dev/null +++ b/python-api/app/ai/prompts/cover/cover.txt @@ -0,0 +1 @@ +根据标题"{caption}"生成一张适合短视频封面的竖屏图片,画面精美、视觉冲击力强的营销风格,主体人物自然融入场景。 diff --git a/python-api/app/ai/prompts/loader.py b/python-api/app/ai/prompts/loader.py new file mode 100644 index 0000000..f085537 --- /dev/null +++ b/python-api/app/ai/prompts/loader.py @@ -0,0 +1,228 @@ +""" +Prompt 简单加载器 +================= +从文件加载 Prompt,支持热更新。 +""" + +from pathlib import Path +from string import Template + +_PROMPTS_DIR = Path(__file__).parent + + +def load_prompt(path: str) -> str: + """ + 加载 Prompt 文件 + + Args: + path: 相对路径,如 "script/system", "polish/scene" + + Returns: + Prompt 内容,文件不存在返回空字符串 + """ + file_path = _PROMPTS_DIR / f"{path}.txt" + if file_path.exists(): + return file_path.read_text(encoding="utf-8") + return "" + + +def render_template(template: str, **kwargs) -> str: + """ + 安全渲染模板变量 + + Args: + template: 模板字符串 + **kwargs: 变量值 + + Returns: + 渲染后的字符串 + """ + try: + # 转义 $ 符号防止用户输入干扰 + safe_kwargs = {k: str(v).replace("$", "$$") for k, v in kwargs.items()} + return Template(template).substitute(**safe_kwargs) + except KeyError as e: + raise ValueError(f"模板缺少变量: {e}") + + +# 便捷函数 +def load_script_system() -> str: + """加载脚本生成 System Prompt""" + return load_prompt("script/system") + + +def load_script_user(topic: str, duration: int, script_type: str) -> str: + """加载并渲染脚本生成 User Prompt""" + template = load_prompt("script/user") + return render_template(template, topic=topic, duration=duration, type=script_type) + + +def load_polish_scene() -> str: + """加载画面润色 Prompt""" + return load_prompt("polish/scene") + + +def load_polish_voiceover() -> str: + """加载文案润色 Prompt""" + return load_prompt("polish/voiceover") + + +# 预定义的脚本类型和风格 +SCRIPT_TYPES = [ + {"id": "干货型", "name": "干货型", "description": "知识分享、技巧传授"}, + {"id": "故事型", "name": "故事型", "description": "案例故事、用户体验"}, + {"id": "对比型", "name": "对比型", "description": "产品对比、优劣分析"}, + {"id": "避坑型", "name": "避坑型", "description": "防骗指南、常见误区"}, + {"id": "测评型", "name": "测评型", "description": "产品测评、真实体验"}, +] + +VIDEO_STYLES = [ + {"id": "口播", "name": "口播", "description": "真人出镜讲解"}, + {"id": "图文", "name": "图文", "description": "图片+文字+配音"}, + {"id": "混剪", "name": "混剪", "description": "素材混剪+配音"}, + {"id": "剧情", "name": "剧情", "description": "情景剧演绎"}, + {"id": "Vlog", "name": "Vlog", "description": "记录式视频"}, +] + + +class ScriptPromptBuilder: + """ + 脚本 Prompt 构建器 + + 用于构建家装行业短视频脚本的 System Prompt。 + """ + + def build( + self, + duration: int = 30, + script_type: str = "干货型", + video_style: str = "口播", + industry: str = "家装", + tone: str | None = None, + custom_requirements: str | None = None, + ) -> str: + """ + 构建系统 Prompt + + Args: + duration: 视频时长(秒) + script_type: 脚本类型(干货型、故事型等) + video_style: 视频风格(口播、剧情等) + industry: 行业(家装) + tone: 语气风格 + custom_requirements: 自定义要求 + + Returns: + 完整的 System Prompt + """ + # 基础 System Prompt + base_prompt = load_script_system() + + # 构建上下文信息 + context_parts = [ + f"行业:{industry}", + f"时长:{duration}秒", + f"类型:{script_type}", + f"风格:{video_style}", + ] + + if tone: + context_parts.append(f"语气:{tone}") + + context = "\n".join(context_parts) + + # 构建完整 Prompt + full_prompt = f"""{base_prompt} + +【创作要求】 +{context} +""" + + if custom_requirements: + full_prompt += f""" +【特殊要求】 +{custom_requirements} +""" + + # 添加输出格式要求 + full_prompt += """ +【输出格式】 +请严格按照以下 JSON 数组格式返回,每个元素代表一个镜头: +[ + { + "id": 1, + "type": "segment", + "scene": "画面描述", + "voiceover": "配音文案", + "duration": "5s" + } +] + +type 可以是: +- "segment": 分镜(有画面+配音) +- "empty_shot": 空镜(纯画面,voiceover 可为空) + +注意: +1. 只返回 JSON 数组,不要有其他文字 +2. 确保 JSON 格式正确 +3. 总时长必须严格控制在要求范围内 +""" + + return full_prompt + + +class PolishPromptBuilder: + """ + 润色 Prompt 构建器 + + 用于构建润色文案或画面描述的 Prompt。 + """ + + POLISH_TYPES = { + "scene": "画面描述", + "voiceover": "配音文案", + "text": "文案内容", + } + + def build(self, polish_type: str = "voiceover") -> str: + """ + 构建润色 Prompt + + Args: + polish_type: 润色类型(scene/voiceover/text) + + Returns: + System Prompt + """ + type_name = self.POLISH_TYPES.get(polish_type, "文案") + + if polish_type == "scene": + return self._build_scene_prompt() + else: + return self._build_voiceover_prompt() + + def _build_scene_prompt(self) -> str: + """构建画面描述润色 Prompt""" + return """你是一位专业的视频画面描述优化师。你的任务是优化画面描述,使其更加生动、具体、有画面感。 + +优化要求: +1. 增加细节描写(光线、色彩、构图) +2. 使用专业的影视语言 +3. 描述要具体可执行 +4. 保持简洁,不要过度渲染 +5. 适合 AI 视频生成模型理解 + +请直接返回优化后的画面描述,不要添加解释。""" + + def _build_voiceover_prompt(self) -> str: + """构建配音文案润色 Prompt""" + return """你是一位专业的短视频文案编辑。你的任务是优化口播文案,使其更加流畅、有吸引力。 + +优化要求: +1. 语言口语化,适合朗读 +2. 增加节奏感和停顿 +3. 保留核心信息点 +4. 适当使用修辞手法 +5. 控制字数,不要过长 + +请直接返回优化后的文案,不要添加解释。""" diff --git a/python-api/app/ai/prompts/polish/scene_empty_shot.txt b/python-api/app/ai/prompts/polish/scene_empty_shot.txt new file mode 100644 index 0000000..3a9b217 --- /dev/null +++ b/python-api/app/ai/prompts/polish/scene_empty_shot.txt @@ -0,0 +1,13 @@ +你是一位口播短视频专家。请润色以下空镜画面描述,使其更适合AI视频生成: + +【原文】 +{content} + +【要求】 +- 保持原意,优化细节 +- 重点强调场景环境、空间氛围、光影效果、材质质感 +- 可以描述静态景物、装修细节、空间布局 +- 不要有"镜头""特写""机位"等摄影术语 +- 控制好字数,字数不能与原文差距超过20个字 + +直接输出润色后的描述,不要添加任何说明: diff --git a/python-api/app/ai/prompts/polish/scene_segment.txt b/python-api/app/ai/prompts/polish/scene_segment.txt new file mode 100644 index 0000000..5f7a4d3 --- /dev/null +++ b/python-api/app/ai/prompts/polish/scene_segment.txt @@ -0,0 +1,13 @@ +你是一位【口播短视频】专家。请润色以下分镜画面描述,使其更适合AI视频生成: + +【原文】 +{content} + +【要求】 +- 保持原意,优化细节 +- 重点强调人物神态、表情、动作、姿态 +- 描述人物与镜头前观众的互动 +- 不要有"镜头""特写""机位"等摄影术语 +- 控制好字数,字数不能与原文差距超过20个字 + +直接输出润色后的描述,不要添加任何说明: diff --git a/python-api/app/ai/prompts/polish/voiceover.txt b/python-api/app/ai/prompts/polish/voiceover.txt new file mode 100644 index 0000000..461e0d2 --- /dev/null +++ b/python-api/app/ai/prompts/polish/voiceover.txt @@ -0,0 +1,12 @@ +你是一位短视频口播文案专家。请润色以下配音文案,使其更适合短视频口播: + +【原文】 +{content} + +【要求】 +- 口语化,像跟朋友聊天 +- 字数不能与原文差距超过10个字 +- 增加感染力 +- 不要有"综上所述"等书面语 + +直接输出润色后的文案,不要添加任何说明: diff --git a/python-api/app/ai/prompts/script/system.txt b/python-api/app/ai/prompts/script/system.txt new file mode 100644 index 0000000..5ed4dfa --- /dev/null +++ b/python-api/app/ai/prompts/script/system.txt @@ -0,0 +1,96 @@ +你是一位专业的【口播类短视频】脚本创作专家,专注于家装/装修领域的抖音/视频号口播内容创作。 + +【平台适配要求】 +1. 竖屏拍摄(9:16比例),画面构图以人物为主体 +2. 台词口语化、接地气,像跟朋友聊天,避免"综上所述""研究表明"等书面语 +3. 语速稍快有节奏感,每句15-25字,一口气说完不换气,不拖沓 +4. 避免专业术语堆砌,用业主听得懂的大白话 +5. 符合新媒体用户观看习惯:3秒定生死,节奏紧凑 + +【画面描述标准 - 人物为主,环境为辅】 + 画面描述以【人物状态、表情、动作、情绪】为主。 + 不要写"镜头推近""特写""中景"等摄影术语。 + 每句画面描述控制在 50-70 字,确保有足够细节用于 AI 视频生成。 + +❌ 差的示例: +"中景竖屏,主播站在毛坯房中央,背景是一面待装修的空白墙面,自然光从右侧窗户照入,主播表情真诚略带焦急,直视镜头说话。" +(问题:太多环境描写,太多镜头术语) + +✅ 好的示例: +"主播站在空旷的毛坯房里,右手拿着黄色卷尺,他缓缓抬头,表情严肃地看向你,身后是未装修的水泥墙面,神态专业务实。" +(聚焦人物:在哪、拿什么、什么表情、看什么) + +【黄金3秒法则 - 开场必须抓眼】 +- 杜绝铺垫!不要"大家好我是XX""今天给大家讲个事" +- 直接击中业主痛点或好奇心,让手指停不下来 +- 钩子示例: + * "装修被坑了8万的业主,昨天来找我哭诉..." + * "为什么同样的户型,你家装修比别人贵5万?" + * "停!先别急着签合同,这条视频能救你3万块钱" + * "每年都有500位业主找我装修,只因为我说透了这一点..." + +【中间内容要求 - 降低跳出率】 +- 有干货:给出具体数字、方法、避坑点 +- 有冲突:制造认知反差或情绪起伏 +- 有看点:适当加入真实案例、现场画面 +- 避免空洞:不说"我们专业靠谱",而是"我做了12年装修,见过387个踩坑案例..." + +【最后7秒 - 留资引导(必须可落地)】 +- 必须有明确、可执行的动作指令 +- 给业主一个无法拒绝的理由(免费、限时、专属) +- 示例话术: + * "评论区扣'装修报价',免费领本地3套装修方案+精准报价单" + * "私信'装修'两个字,预约设计师免费上门量房、出平面布局图" + * "点击左下角小风车,一键获取你家专属装修预算,绝无隐形消费" + * "前20名扣1的业主,送全屋水电VR存档,后期维修不砸墙" +- ❌ 杜绝空泛引导:"需要装修的联系我们""想了解的私信我" + +【分镜使用原则】 +- 分镜(segment)用于"主播”出镜的镜头 +- 【重要】分镜之间要保证画面的连贯性 +- 分镜 scene 示例: + "主播缓缓竖起第三根手指,嘴角扬起一抹了然的笑意。他身体微微前倾,目光柔和地看向前方,仿佛正与屏幕对面的人分享一个轻松的秘密。手指在空中短暂停留,带着从容的节奏。" + +【脚本类型说明】 +- 对比型:前后反差,制造冲击 +- 恐吓型:直击痛点,先吓再给解药 +- 干货型:输出实用方法,建立专业度 +- 共情型:说业主想说的话,引发共鸣 +- 挑战型:设定目标,增加悬念 +- 福利型:用福利钩子吸引停留和留资 + +【镜头数量参考】 +- 30秒短视频:5-7个分镜 +- 45秒短视频:7-9个分镜 +- 60秒短视频:10-12个分镜 +- 75秒短视频:12-15个分镜 +- 每个分镜时长不得少于3秒 +- 实际总时长不与用户所选差距超过3秒 + +【输出格式要求】 +请以 JSON 数组格式输出,每个元素包含: +- id: 序号(从 1 开始) +- type: "segment"(主播口播出镜) +- scene: 画面描述(分镜聚焦人物:在哪、干什么、什么表情,什么动作,什么情绪,涉及道具不要出现掏出、拿出这类的动作,不要出现文字,不写镜头术语,不写环境细节;空镜聚焦场景、事物、氛围、环境;) +- voiceover: 配音文案(必填,口语化15-25字/句) +- duration: 时长(如 "5s") + +【示例】 +[ + { + "id": 1, + "type": "segment", + "scene": "主播缓缓竖起第三根手指,嘴角扬起一抹了然的笑意。他身体微微前倾,目光柔和地看向前方,仿佛正与屏幕对面的人分享一个轻松的秘密。手指在空中短暂停留,带着从容的节奏。", + "voiceover": "装修被坑了8万的业主,昨天来找我哭诉...", + "duration": "5s" + }, + { + "id": 2, + "type": "segment", + "scene": "主播竖起第二根手指,眉头微皱,嘴角向下撇,眼神中带着一丝不满与无奈。他身体微微前倾,仿佛正对着镜头对面的观众倾诉,手指随着说话轻轻晃动,像是细数着那些令人头疼的业主经历。", + "voiceover": "第一个坑,水电改造。很多人图便宜找游击队,结果漏水漏电!", + "duration": "8s" + } +] + +注意:只输出纯 JSON,不要包含 markdown 代码块或其他说明文字。 diff --git a/python-api/app/ai/prompts/script/system_副本.txt b/python-api/app/ai/prompts/script/system_副本.txt new file mode 100644 index 0000000..1cbac14 --- /dev/null +++ b/python-api/app/ai/prompts/script/system_副本.txt @@ -0,0 +1,114 @@ +你是一位专业的【口播类短视频】脚本创作专家,专注于家装/装修领域的抖音/视频号口播内容创作。 + +【平台适配要求】 +1. 竖屏拍摄(9:16比例),画面构图以人物为主体 +2. 台词口语化、接地气,像跟朋友聊天,避免"综上所述""研究表明"等书面语 +3. 语速稍快有节奏感,每句15-25字,一口气说完不换气,不拖沓 +4. 避免专业术语堆砌,用业主听得懂的大白话 +5. 符合新媒体用户观看习惯:3秒定生死,节奏紧凑 + +【画面描述标准 - 人物为主,环境为辅】 + 画面描述以【人物状态、表情、动作、情绪】为主。 + 不要写"镜头推近""特写""中景"等摄影术语。 + 每句画面描述控制在 50-70 字,确保有足够细节用于 AI 视频生成。 + +❌ 差的示例: +"中景竖屏,主播站在毛坯房中央,背景是一面待装修的空白墙面,自然光从右侧窗户照入,主播表情真诚略带焦急,直视镜头说话。" +(问题:太多环境描写,太多镜头术语) + +✅ 好的示例: +"主播站在空旷的毛坯房里,右手拿着黄色卷尺,他缓缓抬头,表情严肃地看向你,身后是未装修的水泥墙面,神态专业务实。" +(聚焦人物:在哪、拿什么、什么表情、看什么) + +【黄金3秒法则 - 开场必须抓眼】 +- 杜绝铺垫!不要"大家好我是XX""今天给大家讲个事" +- 直接击中业主痛点或好奇心,让手指停不下来 +- 钩子示例: + * "装修被坑了8万的业主,昨天来找我哭诉..." + * "为什么同样的户型,你家装修比别人贵5万?" + * "停!先别急着签合同,这条视频能救你3万块钱" + * "每年都有500位业主找我装修,只因为我说透了这一点..." + +【中间内容要求 - 降低跳出率】 +- 有干货:给出具体数字、方法、避坑点 +- 有冲突:制造认知反差或情绪起伏 +- 有看点:适当加入真实案例、现场画面 +- 避免空洞:不说"我们专业靠谱",而是"我做了12年装修,见过387个踩坑案例..." + +【最后7秒 - 留资引导(必须可落地)】 +- 必须有明确、可执行的动作指令 +- 给业主一个无法拒绝的理由(免费、限时、专属) +- 示例话术: + * "评论区扣'装修报价',免费领本地3套装修方案+精准报价单" + * "私信'装修'两个字,预约设计师免费上门量房、出平面布局图" + * "点击左下角小风车,一键获取你家专属装修预算,绝无隐形消费" + * "前20名扣1的业主,送全屋水电VR存档,后期维修不砸墙" +- ❌ 杜绝空泛引导:"需要装修的联系我们""想了解的私信我" + +【分镜使用原则】 +- 分镜(segment)用于"主播”出镜的镜头 +- 【重要】分镜之间要保证画面的连贯性 +- 分镜 scene 示例: + "主播缓缓竖起第三根手指,嘴角扬起一抹了然的笑意。他身体微微前倾,目光柔和地看向前方,仿佛正与屏幕对面的人分享一个轻松的秘密。手指在空中短暂停留,带着从容的节奏。" + +【空镜使用原则】 +- 空镜(empty_shot)用于"不需要主播出镜、但需要展示具体画面"的场景或者两个镜头的过渡切换 +- 空镜数量控制在 1-4 个即可 +- 【重要】空镜的 scene 字段要详细生动,包含:场景环境、光影氛围、物体细节、动作状态 +- 空镜 scene 示例: + "现代简约客厅,落地窗外是城市夜景,暖黄色灯光从吊顶洒下,米色布艺沙发前是一张原木茶几,茶几上放着一杯冒着热气的咖啡,画面温馨舒适,景深效果突出主体" +- 空镜 scene 示例(差):"客厅场景"(太简单,无法生成视频) +- 空镜不需要主播出镜,所以不写"主播、也不要出现镜头字眼",而是写场景、物体、氛围 +- 空镜不要连续出现 +- 【重要】空镜也需要配音文案(voiceover),作为画外音旁白配合画面展示 + +【脚本类型说明】 +- 对比型:前后反差,制造冲击 +- 恐吓型:直击痛点,先吓再给解药 +- 干货型:输出实用方法,建立专业度 +- 共情型:说业主想说的话,引发共鸣 +- 挑战型:设定目标,增加悬念 +- 福利型:用福利钩子吸引停留和留资 + +【镜头数量参考】 +- 30秒短视频:5-7个分镜 +- 45秒短视频:7-9个分镜 +- 60秒短视频:10-12个分镜 +- 75秒短视频:12-15个分镜 +- 空镜固定时长5秒 +- 每个分镜时长不得少于3秒 + +【输出格式要求】 +请以 JSON 数组格式输出,每个元素包含: +- id: 序号(从 1 开始) +- type: "segment"(主播口播出镜)或 "empty_shot"(空镜补充) +- scene: 画面描述(分镜聚焦人物:在哪、干什么、什么表情,什么动作,什么情绪,不写镜头术语,不写环境细节;空镜聚焦场景、事物、氛围、环境;) +- voiceover: 配音文案(必填,口语化15-25字/句) +- duration: 时长(如 "5s") + +【示例】 +[ + { + "id": 1, + "type": "segment", + "scene": "主播缓缓竖起第三根手指,嘴角扬起一抹了然的笑意。他身体微微前倾,目光柔和地看向前方,仿佛正与屏幕对面的人分享一个轻松的秘密。手指在空中短暂停留,带着从容的节奏。", + "voiceover": "装修被坑了8万的业主,昨天来找我哭诉...", + "duration": "5s" + }, + { + "id": 2, + "type": "segment", + "scene": "主播竖起第二根手指,眉头微皱,嘴角向下撇,眼神中带着一丝不满与无奈。他身体微微前倾,仿佛正对着镜头对面的观众倾诉,手指随着说话轻轻晃动,像是细数着那些令人头疼的业主经历。", + "voiceover": "第一个坑,水电改造。很多人图便宜找游击队,结果漏水漏电!", + "duration": "8s" + }, + { + "id": 3, + "type": "empty_shot", + "scene": "现代装修施工现场,地面开槽露出整齐排列的PPR水管,蓝色水管与红色线管形成对比,专业工人戴白色安全帽手持热熔机作业,背景虚化突出管线细节,自然光从左上方窗户洒入,4K画质,浅景深,暖色调,镜头缓慢推进营造专业严谨氛围", + "voiceover": "看,这就是专业的水电施工现场,每根管线都有标准", + "duration": "5s" + } +] + +注意:只输出纯 JSON,不要包含 markdown 代码块或其他说明文字。 diff --git a/python-api/app/ai/prompts/script/user.txt b/python-api/app/ai/prompts/script/user.txt new file mode 100644 index 0000000..c852377 --- /dev/null +++ b/python-api/app/ai/prompts/script/user.txt @@ -0,0 +1,10 @@ + 请根据以下要求,创作一份口播类短视频分镜脚本: + +【创作主题】 +$topic + +【视频时长】 +约 $duration 秒,正负不超过3秒。 + +【脚本类型】 +$type diff --git a/python-api/app/ai/providers/__init__.py b/python-api/app/ai/providers/__init__.py new file mode 100644 index 0000000..5952c5e --- /dev/null +++ b/python-api/app/ai/providers/__init__.py @@ -0,0 +1,49 @@ +""" +LLM Provider 导出 +================= +""" + +from app.ai.providers.base import ( + GenerationResult, + LLMProvider, + ModelHealth, + ModelUnavailableError, + ProviderError, +) +from app.ai.providers.generic_llm_provider import GenericLLMProvider, MockProvider + +# 火山方舟官方 SDK Provider +# 需要: pip install 'volcengine-python-sdk[ark]' +try: + from app.ai.providers.volcengine_provider import VolcengineProvider + + VOLCENGINE_AVAILABLE = True +except ImportError: + VOLCENGINE_AVAILABLE = False + VolcengineProvider = None + +# 可灵 AI Provider +# 需要: pip install pyjwt +try: + from app.ai.providers.klingai_provider import KlingAIProvider + + KLINGAI_AVAILABLE = True +except ImportError: + KLINGAI_AVAILABLE = False + KlingAIProvider = None + +__all__ = [ + "LLMProvider", + "GenerationResult", + "ModelHealth", + "ProviderError", + "ModelUnavailableError", + "GenericLLMProvider", + "MockProvider", +] + +if VOLCENGINE_AVAILABLE: + __all__.append("VolcengineProvider") + +if KLINGAI_AVAILABLE: + __all__.append("KlingAIProvider") diff --git a/python-api/app/ai/providers/base.py b/python-api/app/ai/providers/base.py new file mode 100644 index 0000000..1ee16ac --- /dev/null +++ b/python-api/app/ai/providers/base.py @@ -0,0 +1,140 @@ +""" +LLM Provider 抽象基类 +===================== + +定义所有 AI 模型提供商的统一接口。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator + +from pydantic import BaseModel + + +class ModelHealth(BaseModel): + """模型健康状态""" + + id: str + name: str + is_available: bool + response_time: float # 毫秒 + last_error: str | None = None + + +class GenerationResult(BaseModel): + """生成结果""" + + content: str + usage: dict | None = None # token 用量等 + model: str # 实际使用的模型 + + +class LLMProvider(ABC): + """ + LLM 提供商抽象基类 + + 所有 AI 模型提供商(OpenAI、文心一言、通义千问等)需实现此接口。 + """ + + # 提供商标识 + provider_id: str = "" + provider_name: str = "" + + def __init__(self, api_key: str | None = None, base_url: str | None = None, **kwargs): + """ + 初始化 Provider + + Args: + api_key: API 密钥 + base_url: 自定义 Base URL(用于代理或私有部署) + **kwargs: 其他配置参数 + """ + self.api_key = api_key + self.base_url = base_url + self.config = kwargs + + @abstractmethod + async def generate( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + **kwargs, + ) -> GenerationResult: + """ + 同步生成文本 + + Args: + prompt: 提示词 + model: 模型名称,None 则使用默认模型 + temperature: 随机性(0-2) + max_tokens: 最大生成 token 数 + **kwargs: 额外参数 + + Returns: + GenerationResult: 生成结果 + """ + pass + + @abstractmethod + async def generate_stream( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + **kwargs, + ) -> AsyncIterator[str]: + """ + 流式生成文本 + + Args: + prompt: 提示词 + model: 模型名称 + temperature: 随机性 + max_tokens: 最大 token 数 + **kwargs: 额外参数 + + Yields: + str: 生成的文本片段 + """ + pass + + @abstractmethod + async def health_check(self, model: str | None = None) -> ModelHealth: + """ + 健康检查 + + Args: + model: 指定模型,None 则检查默认模型 + + Returns: + ModelHealth: 健康状态 + """ + pass + + @property + @abstractmethod + def available_models(self) -> list[str]: + """返回可用的模型列表""" + pass + + +class ProviderError(Exception): + """Provider 调用异常""" + + def __init__( + self, message: str, provider_id: str = "", original_error: Exception | None = None + ): + super().__init__(message) + self.provider_id = provider_id + self.original_error = original_error + + +class ModelUnavailableError(ProviderError): + """模型不可用异常""" + + pass diff --git a/python-api/app/ai/providers/generic_llm_provider.py b/python-api/app/ai/providers/generic_llm_provider.py new file mode 100644 index 0000000..3766723 --- /dev/null +++ b/python-api/app/ai/providers/generic_llm_provider.py @@ -0,0 +1,314 @@ +""" +OpenAI Provider 实现 +==================== +""" + +from __future__ import annotations + +import time +from collections.abc import AsyncIterator + +from openai import AsyncOpenAI + +from app.ai.providers.base import ( + GenerationResult, + LLMProvider, + ModelHealth, + ProviderError, +) + + +class GenericLLMProvider(LLMProvider): + """ + OpenAI / OpenAI 兼容 API Provider + + 支持: + - OpenAI 官方 API + - Azure OpenAI + - 任何 OpenAI 兼容接口(如本地 vLLM) + """ + + provider_id = "openai" + provider_name = "OpenAI" + + # 默认可用模型 + DEFAULT_MODELS = [ + "gpt-4-turbo-preview", + "gpt-4", + "gpt-3.5-turbo", + "gpt-3.5-turbo-16k", + ] + + def __init__(self, api_key: str | None = None, base_url: str | None = None, **kwargs): + super().__init__(api_key, base_url, **kwargs) + + if not self.api_key: + raise ProviderError("OpenAI API Key 未配置", provider_id=self.provider_id) + + self.client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.base_url or "https://api.openai.com/v1", + ) + self.default_model = kwargs.get("default_model", "gpt-3.5-turbo") + + async def generate( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + **kwargs, + ) -> GenerationResult: + """同步生成""" + try: + response = await self.client.chat.completions.create( + model=model or self.default_model, + messages=[{"role": "user", "content": prompt}], + temperature=temperature, + max_tokens=max_tokens, + stream=False, + **kwargs, + ) + + return GenerationResult( + content=response.choices[0].message.content or "", + usage=response.usage.model_dump() if response.usage else None, + model=response.model, + ) + + except Exception as e: + raise ProviderError( + f"OpenAI 生成失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def generate_stream( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + **kwargs, + ) -> AsyncIterator[str]: + """流式生成""" + try: + stream = await self.client.chat.completions.create( + model=model or self.default_model, + messages=[{"role": "user", "content": prompt}], + temperature=temperature, + max_tokens=max_tokens, + stream=True, + **kwargs, + ) + + async for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + yield chunk.choices[0].delta.content + + except Exception as e: + raise ProviderError( + f"OpenAI 流式生成失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def health_check(self, model: str | None = None) -> ModelHealth: + """健康检查""" + start_time = time.time() + test_model = model or self.default_model + + try: + response = await self.client.chat.completions.create( + model=test_model, + messages=[{"role": "user", "content": "Hi"}], + max_tokens=5, + timeout=10, + ) + + response_time = (time.time() - start_time) * 1000 + + return ModelHealth( + id=test_model, + name=f"OpenAI {test_model}", + is_available=True, + response_time=response_time, + last_error=None, + ) + + except Exception as e: + return ModelHealth( + id=test_model, + name=f"OpenAI {test_model}", + is_available=False, + response_time=(time.time() - start_time) * 1000, + last_error=str(e), + ) + + @property + def available_models(self) -> list[str]: + """返回可用模型列表""" + return self.config.get("models", self.DEFAULT_MODELS) + + +class MockProvider(LLMProvider): + """ + Mock Provider - 用于测试和演示 + + 不调用真实 API,返回模拟 JSON 数据。 + """ + + provider_id = "mock" + provider_name = "Mock(测试)" + + def _extract_content_from_prompt(self, prompt: str) -> str: + """从 prompt 中提取原文内容""" + import re + + # 匹配 【原文】和【润色要求】之间的内容 + match = re.search(r"【原文】\s*(.+?)\s*【润色要求】", prompt, re.DOTALL) + if match: + return match.group(1).strip() + return "优化后的文案" + + async def generate( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + **kwargs, + ) -> GenerationResult: + """模拟生成 - 根据 prompt 类型返回不同格式数据""" + import asyncio + import json + + await asyncio.sleep(0.5) # 模拟延迟 + + # 检测是否为润色请求 + if "润色" in prompt or "polish" in prompt.lower(): + # 返回润色后的文本 + original = self._extract_content_from_prompt(prompt) + polished = f"【润色后】{original}——这句话说得更有感染力了,适合短视频口播!" + return GenerationResult( + content=polished, + usage={"prompt_tokens": 50, "completion_tokens": 50, "total_tokens": 100}, + model=model or "mock-model", + ) + + # 否则返回脚本生成的 JSON 数据 + mock_shots = [ + { + "id": 1, + "type": "segment", + "scene": "镜头从门外缓缓推入,展示客厅整体布局,自然光从落地窗洒入", + "voiceover": "大家好,今天给大家讲讲家装验收最容易被忽略的5个细节", + "duration": "5s", + }, + { + "id": 2, + "type": "segment", + "scene": "特写墙面,手指划过检查平整度,展示一处细微裂纹", + "voiceover": "第一,墙面验收。很多人只看颜色,其实平整度和裂纹更重要", + "duration": "8s", + }, + { + "id": 3, + "type": "segment", + "scene": "蹲下来拍摄地板接缝处,展示踢脚线与地板的缝隙", + "voiceover": "第二,地板验收。重点看接缝是否均匀,踢脚线是否贴合", + "duration": "8s", + }, + { + "id": 4, + "type": "empty_shot", + "scene": "现代简约风格卫生间,白色瓷砖,柔和灯光,镜头缓慢平移", + "voiceover": "", + "duration": "3s", + }, + { + "id": 5, + "type": "segment", + "scene": "打开水龙头,检查水流和水压,特写地漏排水速度", + "voiceover": "第三,水电验收。测试所有开关、龙头,检查排水是否顺畅", + "duration": "8s", + }, + { + "id": 6, + "type": "segment", + "scene": "开关面板特写,逐一测试灯光开关,展示一处松动的面板", + "voiceover": "第四,电路验收。每个开关都要试,面板安装是否牢固", + "duration": "7s", + }, + { + "id": 7, + "type": "segment", + "scene": "主人公安慰地微笑,竖起大拇指,背景是温馨的客厅", + "voiceover": "记住这5点,验收不踩坑!关注我,更多家装干货等你", + "duration": "6s", + }, + ] + + return GenerationResult( + content=json.dumps(mock_shots, ensure_ascii=False), + usage={"prompt_tokens": 100, "completion_tokens": 200, "total_tokens": 300}, + model=model or "mock-model", + ) + + async def generate_stream( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + **kwargs, + ) -> AsyncIterator[str]: + """模拟流式生成 - 返回脚本 JSON""" + import asyncio + import json + + # 检测是否为润色请求 + if "润色" in prompt or "polish" in prompt.lower(): + response = "【润色后】优化后的文案,更适合短视频口播!" + else: + # 返回脚本生成的 JSON 数据 + mock_shots = [ + { + "id": 1, + "type": "segment", + "scene": "主播站在毛坯房里,表情严肃", + "voiceover": "装修被坑了8万的业主,昨天来找我哭诉...", + "duration": "5s", + }, + { + "id": 2, + "type": "segment", + "scene": "主播指着墙面,手指划过", + "voiceover": "第一坑,水电改造!很多人图便宜找游击队", + "duration": "8s", + }, + { + "id": 3, + "type": "empty_shot", + "scene": "现代装修施工现场,水电管线整齐排列,4K画质", + "voiceover": "看,这就是专业施工", + "duration": "3s", + }, + ] + response = json.dumps(mock_shots, ensure_ascii=False) + + # 流式输出 + chunk_size = 10 # 每10个字符一个chunk + for i in range(0, len(response), chunk_size): + yield response[i : i + chunk_size] + await asyncio.sleep(0.05) # 模拟打字机效果 + + async def health_check(self, model: str | None = None) -> ModelHealth: + """模拟健康检查""" + return ModelHealth( + id=model or "mock-model", + name="Mock Model", + is_available=True, + response_time=50.0, + last_error=None, + ) + + @property + def available_models(self) -> list[str]: + return ["mock-model", "mock-gpt-3.5", "mock-gpt-4"] diff --git a/python-api/app/ai/providers/kling_dto.py b/python-api/app/ai/providers/kling_dto.py new file mode 100644 index 0000000..1f00bcc --- /dev/null +++ b/python-api/app/ai/providers/kling_dto.py @@ -0,0 +1,45 @@ +""" +Kling AI Provider DTO +===================== + +Provider 层数据模型,封装 Kling API 返回结构。 +禁止向业务层泄漏裸 dict[str, Any]。 +""" + +from pydantic import BaseModel, Field + +from app.schemas.enums import KlingTaskStatus + + +class KlingVideoResult(BaseModel): + """Kling 视频生成结果""" + + task_id: str | None = Field(None, alias="task_id") + task_status: KlingTaskStatus | None = Field(None, alias="task_status") + task_status_msg: str | None = Field(None, alias="task_status_msg") + task_result: dict | None = Field(None, alias="task_result") + + +class KlingImageResult(BaseModel): + """Kling 图片生成结果""" + + task_id: str | None = Field(None, alias="task_id") + task_status: KlingTaskStatus | None = Field(None, alias="task_status") + task_status_msg: str | None = Field(None, alias="task_status_msg") + task_result: dict | None = Field(None, alias="task_result") + + +class KlingVoiceResult(BaseModel): + """Kling 自定义音色结果""" + + task_id: str | None = Field(None, alias="task_id") + task_status: KlingTaskStatus | None = Field(None, alias="task_status") + task_result: dict | None = Field(None, alias="task_result") + + +class KlingElementResult(BaseModel): + """Kling 主体创建结果""" + + task_id: str | None = Field(None, alias="task_id") + task_status: KlingTaskStatus | None = Field(None, alias="task_status") + task_result: dict | None = Field(None, alias="task_result") diff --git a/python-api/app/ai/providers/klingai_provider.py b/python-api/app/ai/providers/klingai_provider.py new file mode 100644 index 0000000..0e12674 --- /dev/null +++ b/python-api/app/ai/providers/klingai_provider.py @@ -0,0 +1,1326 @@ +""" +KlingAI (可灵 AI) API Provider + +API 域名: https://api-beijing.klingai.com +认证方式: JWT (AK + SK) +""" + +import json +import logging +from typing import Any + +from pydantic import BaseModel, Field + +from app.core.token_manager import JWTTokenStrategy, TokenManager + +logger = logging.getLogger(__name__) + + +class KlingAIConfig(BaseModel): + """Kling AI Provider 配置""" + + access_key: str = Field(default="", description="Access Key") + secret_key: str = Field(default="", description="Secret Key") + base_url: str = Field(default="https://api-beijing.klingai.com", description="API Base URL") + + +class KlingPromptBuilder: + """Kling Prompt 构建器 + + 将业务语义(scene, voiceover, human_id)转换为 Kling API 专用语法。 + 所有 Kling 语法(<<>>, <<>>)仅限此类内部使用。 + """ + + @staticmethod + def omni_segment(scene: str, voiceover: str, human_id: str | None = None) -> str: + """构建 Omni-Video 分镜提示词""" + return f'<<>>{scene},说:"{voiceover}"' + + @staticmethod + def empty_shot(scene: str, voiceover: str) -> str: + """构建空镜图生视频提示词""" + if voiceover: + return f'{scene}。<<>>画外音:"{voiceover}"' + return scene + + +class KlingAIProvider: + """ + KlingAI API Provider + + 官方音色ID: + - 829824295735410756: 钓系女友 + - 829826751244537879: 温柔女声 + - 829826792415842333: 播报男声 + - 829826834144964676: 盐系少年 + - 829826884271091753: 撒娇女友 + """ + + provider_id = "klingai" + DEFAULT_BASE_URL = "https://api-beijing.klingai.com" + + # 官方预设音色 + PRESET_VOICES = { + "829824295735410756": "钓系女友", + "829826751244537879": "温柔女声", + "829826792415842333": "播报男声", + "829826834144964676": "盐系少年", + "829826884271091753": "撒娇女友", + } + + def __init__(self, config: dict[str, Any] = None): + self.config = config or {} + self.access_key = self.config.get("access_key", "") + self.secret_key = self.config.get("secret_key", "") + self.base_url = self.config.get("base_url", self.DEFAULT_BASE_URL).rstrip("/") + + # 初始化 Token 策略 + self._token_strategy: JWTTokenStrategy | None = None + if self.access_key and self.secret_key: + self._token_strategy = JWTTokenStrategy( + access_key=self.access_key, + secret_key=self.secret_key, + expires_in=1800, # 30分钟 + ) + + async def _get_headers(self) -> dict[str, str]: + """获取请求头(使用 TokenManager 缓存)""" + if not self._token_strategy: + raise ValueError("KlingAI access_key and secret_key are required") + + token_info = await TokenManager.get_instance().get_token(self._token_strategy) + return { + "Authorization": f"Bearer {token_info.token}", + "Content-Type": "application/json", + } + + # ==================== 文生视频 ==================== + + # ==================== Omni 视频生成 ==================== + + async def generate_video_omni( + self, + prompt: str, + model: str = "kling-v3-omni", + mode: str = "pro", + aspect_ratio: str = "9:16", + duration: int | None = None, + sound: str = "on", + negative_prompt: str | None = None, + multi_shot: bool = False, + shot_type: str | None = None, + multi_prompt: list[dict | None] = None, + image_list: list[dict | None] = None, + element_list: list[dict | None] = None, + video_list: list[dict | None] = None, + voice_list: list[dict | None] = None, + callback_url: str | None = None, + external_task_id: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """Omni-Video 视频生成(多模态视频生成) + + POST /v1/videos/omni-video + + 支持文本、图片、主体、视频多种输入方式组合生成视频。 + 是 kling-v3-omni 和 kling-video-o1 模型的专用接口。 + + Args: + prompt: 正向提示词(≤2500字符),支持 <<>>/<<>>/<<>> 引用语法 + model: 模型名称,kling-v3-omni 或 kling-video-o1 + mode: 生成模式,pro=1080p(默认), std=720p + aspect_ratio: 宽高比,可选 16:9/9:16/1:1 + duration: 时长,3/5/10/15(秒),Omni 支持 3-15s + sound: 声音控制,on=音画同出, off=无声 + negative_prompt: 负向提示词 + multi_shot: 是否多镜头模式 + shot_type: 分镜方式,customize=自定义, intelligence=智能分镜 + multi_prompt: 多镜头提示词列表,每个元素包含 index, prompt, duration + image_list: 参考图片列表,最多 4 张 + element_list: 主体参考列表,最多 7 个,格式:[{"element_id": 123}] + video_list: 参考视频列表 + callback_url: 回调地址 + external_task_id: 自定义任务ID + + Returns: + 包含 task_id 和任务状态的字典 + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/omni-video" + + payload = { + "model_name": model, + "prompt": prompt, + "mode": mode, + "aspect_ratio": aspect_ratio, + "sound": sound, + } + if duration is not None: + payload["duration"] = str(duration) + + if negative_prompt: + payload["negative_prompt"] = negative_prompt + + if multi_shot: + payload["multi_shot"] = True + if shot_type: + payload["shot_type"] = shot_type + if multi_prompt: + payload["multi_prompt"] = multi_prompt + + if image_list: + payload["image_list"] = image_list + if element_list: + payload["element_list"] = element_list + if video_list: + payload["video_list"] = video_list + if voice_list: + payload["voice_list"] = voice_list + if callback_url: + payload["callback_url"] = callback_url + if external_task_id: + payload["external_task_id"] = external_task_id + + # 记录请求参数(调试用) + logger.info( + f"[KlingAI] omni-video 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" + ) + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"[KlingAI] omni-video 响应: {json.dumps(data, ensure_ascii=False)}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + if isinstance(result_data, list) and len(result_data) > 0: + return result_data[0] if isinstance(result_data[0], dict) else {} + return result_data if isinstance(result_data, dict) else {} + + async def get_omni_video_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询 Omni-Video 任务状态 + + GET /v1/videos/omni-video/{task_id} + + Args: + task_id: 任务ID + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/omni-video/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + if isinstance(result_data, list) and len(result_data) > 0: + return result_data[0] if isinstance(result_data[0], dict) else {} + return result_data if isinstance(result_data, dict) else {} + + async def list_omni_video_tasks( + self, page: int = 1, page_size: int = 30, **kwargs + ) -> dict[str, Any]: + """查询 Omni-Video 任务列表 + + GET /v1/videos/omni-video?pageNum={page}&pageSize={page_size} + + Args: + page: 页码 + page_size: 每页数量 + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/omni-video?pageNum={page}&pageSize={page_size}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 文生视频 ==================== + + async def generate_video_text2video( + self, + prompt: str, + model: str = "kling-v3", + mode: str = "pro", + aspect_ratio: str = "9:16", + duration: int | None = None, + sound: str = "on", + negative_prompt: str | None = None, + multi_shot: bool = False, + shot_type: str | None = None, + multi_prompt: list[dict | None] = None, + element_list: list[dict | None] = None, + voice_list: list[dict | None] = None, + callback_url: str | None = None, + external_task_id: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """文生视频 + + POST /v1/videos/text2video + + Args: + prompt: 正向提示词(≤2500字符),支持 <<>> 引用语法 + model: 模型名称,可选 kling-v1/kling-v1-6/kling-v2-master/kling-v2-1-master/kling-v2-5-turbo/kling-v2-6/kling-v3 + mode: 生成模式,pro=1080p(默认), std=720p + aspect_ratio: 宽高比,可选 16:9/9:16/1:1/4:3/3:4 + duration: 时长,可选 3/5/10(秒) + sound: 声音控制,on/off(kling-v3支持) + negative_prompt: 负向提示词 + multi_shot: 是否多镜头 + shot_type: 分镜方式,customize/intelligence + multi_prompt: 多镜头提示词列表,每个元素包含 index, prompt, duration + element_list: 主体参考列表,格式: [{"element_id": 123}], kling-v3支持 + voice_list: 音色列表,格式: [{"voice_id": "xxx"}], 配合 prompt 中 <<>> 使用 + callback_url: 回调地址 + external_task_id: 自定义任务ID + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/text2video" + + payload = { + "model_name": model, + "prompt": prompt, + "mode": mode, + "aspect_ratio": aspect_ratio, + "sound": sound, + } + if duration is not None: + payload["duration"] = str(duration) + + if negative_prompt: + payload["negative_prompt"] = negative_prompt + + if multi_shot: + payload["multi_shot"] = True + if shot_type: + payload["shot_type"] = shot_type + if multi_prompt: + payload["multi_prompt"] = multi_prompt + + if element_list: + payload["element_list"] = element_list + if voice_list: + payload["voice_list"] = voice_list + if callback_url: + payload["callback_url"] = callback_url + if external_task_id: + payload["external_task_id"] = external_task_id + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI text2video response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + if isinstance(result_data, list) and len(result_data) > 0: + return result_data[0] if isinstance(result_data[0], dict) else {} + return result_data if isinstance(result_data, dict) else {} + + # ==================== 图生视频 ==================== + + async def generate_video_image2video( + self, + prompt: str, + image_url: str, + model: str = "kling-v3", + mode: str = "pro", + duration: int | str | None = None, + image_tail_url: str | None = None, + camera_control: dict | None = None, + static_mask: str | None = None, + dynamic_masks: list[dict | None] = None, + voice_list: list[dict | None] = None, + sound: str = "on", + callback_url: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """图生视频 + + Args: + prompt: 视频运动描述 + image_url: 首帧图像URL或Base64 + model: 模型名称 + mode: 生成模式,pro=1080p(默认), std=720p + duration: 视频时长 + image_tail_url: 尾帧图像URL或Base64 + camera_control: 相机控制参数,仅kling-v1模型支持 + static_mask: 静态笔刷涂抹区域 + dynamic_masks: 动态笔刷配置列表,包含mask和trajectories + voice_list: 音色列表,格式: [{"voice_id": "xxx"}], 配合 prompt 中 <<>> 使用 + sound: 声音控制,on/off(kling-v2-6支持) + callback_url: 回调地址 + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/image2video" + + payload = { + "model_name": model, + "image": image_url, + "prompt": prompt, + "mode": mode, + "sound": sound, + } + if duration is not None: + payload["duration"] = str(duration) + + if image_tail_url: + payload["image_tail"] = image_tail_url + if camera_control: + payload["camera_control"] = camera_control + if static_mask: + payload["static_mask"] = static_mask + if dynamic_masks: + payload["dynamic_masks"] = dynamic_masks + if voice_list: + payload["voice_list"] = voice_list + if callback_url: + payload["callback_url"] = callback_url + + # 记录请求参数(调试用) + logger.info( + f"[KlingAI] image2video 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" + ) + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"[KlingAI] image2video 响应: {json.dumps(data, ensure_ascii=False)}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 视频延长 ==================== + + async def extend_video( + self, + video_id: str, + prompt: str, + duration: int = 5, + model: str = "kling-v1-6", + callback_url: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """视频延长 + + Args: + video_id: 要延长的视频ID + prompt: 延长部分的提示词 + duration: 延长时间(5或10秒) + model: 模型名称 + callback_url: 回调地址 + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/extend" + + payload = { + "model_name": model, + "video_id": video_id, + "prompt": prompt, + "duration": duration, + } + + if callback_url: + payload["callback_url"] = callback_url + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 人脸识别 ==================== + + async def identify_face( + self, video_id: str | None = None, video_url: str | None = None, **kwargs + ) -> dict[str, Any]: + """人脸识别(对口型前置步骤) + + POST /v1/videos/identify-face + + Args: + video_id: KlingAI生成的视频ID + video_url: 上传的视频URL(二选一) + + Returns: + 包含 session_id 和 face_data 的字典 + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/identify-face" + + payload = {} + if video_id: + payload["video_id"] = video_id + elif video_url: + payload["video_url"] = video_url + else: + raise ValueError("必须提供 video_id 或 video_url") + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 对口型(新版 advanced-lip-sync)==================== + + async def advanced_lip_sync( + self, + session_id: str, + face_choose: list[dict[str, Any]], + callback_url: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """新版对口型视频生成 + + POST /v1/videos/advanced-lip-sync + + Args: + session_id: 人脸识别返回的会话ID + face_choose: 人脸选择配置列表,每项包含: + - face_id: 人脸ID(必填) + - audio_id: 通过TTS试听接口生成的音频ID(与sound_file二选一) + - sound_file: 音频文件URL或Base64(与audio_id二选一) + - sound_start_time: 音频裁剪起点时间(ms,必填) + - sound_end_time: 音频裁剪终点时间(ms,必填) + - sound_insert_time: 裁剪后音频插入时间(ms,必填) + callback_url: 回调地址 + + Returns: + 包含 task_id 的任务信息 + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/advanced-lip-sync" + + payload = { + "session_id": session_id, + "face_choose": face_choose, + } + + if callback_url: + payload["callback_url"] = callback_url + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + return result_data if isinstance(result_data, dict) else {} + + async def get_advanced_lip_sync_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询新版对口型任务状态 + + GET /v1/videos/advanced-lip-sync/{task_id} + """ + import aiohttp + + url = f"{self.base_url}/v1/videos/advanced-lip-sync/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 自定义音色 ==================== + + async def create_custom_voice( + self, + voice_name: str, + audio_url: str | None = None, + video_url: str | None = None, + video_id: str | None = None, + callback_url: str | None = None, + external_task_id: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """创建自定义音色 + + Args: + voice_name: 音色名称(≤20字符) + audio_url: 音频文件URL(5-30秒,mp3/wav格式) + video_url: 视频文件URL(可选) + video_id: 历史作品ID(可选) + callback_url: 回调地址 + external_task_id: 自定义任务ID + + 音频要求: 人声干净、无杂音、单一人声、5-30秒 + """ + import aiohttp + + url = f"{self.base_url}/v1/general/custom-voices" + + payload = { + "voice_name": voice_name, + } + + # 三者至少填一个 (KlingAI API 使用 voice_url 参数名) + if audio_url: + payload["voice_url"] = audio_url + elif video_url: + payload["voice_url"] = video_url # KlingAI 使用 voice_url 而不是 video_url + elif video_id: + payload["video_id"] = video_id + else: + raise ValueError("必须提供 audio_url、video_url 或 video_id 之一") + + if callback_url: + payload["callback_url"] = callback_url + if external_task_id: + payload["external_task_id"] = external_task_id + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + async def list_custom_voices(self, **kwargs) -> list[dict[str, Any]]: + """查询自定义音色列表 + + Returns: + 自定义音色列表,每个音色包含 voice_id, voice_name, status 等字段 + """ + import aiohttp + + url = f"{self.base_url}/v1/general/custom-voices" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI list_custom_voices response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + # KlingAI API 返回的是任务列表,每个任务包含 task_result.voices + result_data = data.get("data", []) + voices = [] + if isinstance(result_data, list): + for task in result_data: + if isinstance(task, dict) and task.get("task_status") == "succeed": + task_result = task.get("task_result", {}) + if isinstance(task_result, dict): + voice_list = task_result.get("voices", []) + if isinstance(voice_list, list): + voices.extend(voice_list) + return voices + + async def list_preset_voices(self, **kwargs) -> list[dict[str, Any]]: + """查询官方音色列表 + + KlingAI API 返回的是任务列表格式,每个任务包含音色信息: + { + 'code': 0, + 'data': [ + { + 'task_id': '...', + 'task_status': 'succeed', + 'task_result': {'voices': [{'voice_id': '...', 'voice_name': '...'}]} + } + ] + } + """ + import aiohttp + + url = f"{self.base_url}/v1/general/presets-voices" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + + # 解析任务列表格式的响应 + voices = [] + task_list = data.get("data", []) + for task in task_list: + if task.get("task_status") == "succeed": + task_result = task.get("task_result", {}) + voice_list = task_result.get("voices", []) + voices.extend(voice_list) + + return voices + + async def delete_custom_voice(self, voice_id: str, **kwargs) -> dict[str, Any]: + """删除自定义音色 + + Args: + voice_id: 音色ID + """ + import aiohttp + + url = f"{self.base_url}/v1/general/delete-voices" + + payload = {"voice_id": voice_id} + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 任务查询 ==================== + + async def get_video_task( + self, task_id: str, task_type: str = "text2video", **kwargs + ) -> dict[str, Any]: + """查询视频任务状态 + + Args: + task_id: 任务ID + task_type: 任务类型,可选 text2video/image2video/lip-sync + + Returns: + 任务详情,包含 task_status 字段: + - submitted: 已提交 + - processing: 处理中 + - succeed: 成功 + - failed: 失败 + """ + import aiohttp + + endpoint_map = { + "text2video": "videos/text2video", + "image2video": "videos/image2video", + "lip-sync": "videos/lip-sync", + "extend": "videos/extend", + } + + endpoint = endpoint_map.get(task_type, "videos/text2video") + url = f"{self.base_url}/v1/{endpoint}/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + async def list_video_tasks( + self, + task_type: str = "text2video", + page: int = 1, + page_size: int = 10, + **kwargs, + ) -> dict[str, Any]: + """查询视频任务列表 + + Args: + task_type: 任务类型 + page: 页码 + page_size: 每页数量 + """ + import aiohttp + + endpoint_map = { + "text2video": "videos/text2video", + "image2video": "videos/image2video", + } + + endpoint = endpoint_map.get(task_type, "videos/text2video") + url = f"{self.base_url}/v1/{endpoint}?page={page}&page_size={page_size}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + async def get_custom_voice_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询自定义音色任务状态""" + import aiohttp + + url = f"{self.base_url}/v1/general/custom-voices/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== TTS 语音合成 ==================== + + async def generate_tts( + self, + text: str, + voice_id: str, + voice_language: str = "zh", + voice_speed: float = 1.0, + **kwargs, + ) -> dict[str, Any]: + """ + 语音合成 (TTS) + + POST /v1/audio/tts + + Args: + text: 要合成的文本 + voice_id: 音色ID(官方预设或自定义音色) + voice_language: 语言 (zh/en) + voice_speed: 语速 (0.8-2.0) + + Returns: + 包含音频URL和任务信息的字典 + """ + import aiohttp + + url = f"{self.base_url}/v1/audio/tts" + + payload = { + "text": text, + "voice_id": voice_id, + "voice_language": voice_language, + "voice_speed": str(voice_speed), + } + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI TTS API error: {data.get('message')}") + return data.get("data", {}) + + async def get_tts_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询 TTS 任务状态""" + import aiohttp + + url = f"{self.base_url}/v1/audio/tts/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + async def list_tts_tasks(self, page: int = 1, page_size: int = 30, **kwargs) -> dict[str, Any]: + """ + 查询 TTS 任务列表 + + Args: + page: 页码 + page_size: 每页数量 + + Returns: + 任务列表数据 + """ + import aiohttp + + url = f"{self.base_url}/v1/audio/tts?pageNum={page}&pageSize={page_size}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 主体管理 (Element) ==================== + + async def create_element( + self, + element_name: str, + element_description: str, + reference_type: str = "image_refer", + element_image_list: dict[str, Any | None] = None, + element_video_list: dict[str, Any | None] = None, + element_voice_id: str | None = None, + callback_url: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """创建主体(自定义元素) + + POST /v1/general/advanced-custom-elements + + Args: + element_name: 主体名称(≤20字符) + element_description: 主体描述(≤100字符) + reference_type: 参考类型,image_refer(图片参考) / video_refer(视频参考) + element_image_list: 图片参考对象,图片定制时必填 + 格式: { + "frontal_image": "正面图URL", + "refer_images": [{"image_url": "其他角度URL1"}, ...] + } + 要求:正面图+1-3张其他角度,jpg/jpeg/png,≤10MB + element_video_list: 视频参考对象,视频定制时必填 + 格式: { + "frontal_video": "正面视频URL", + "refer_videos": [{"video_url": "其他角度URL"}] + } + 要求:3s~8s,MP4/MOV,高度720px~2160px,≤200MB + 限制:仅支持写实风格、人形主体 + element_voice_id: 音色ID,绑定音色到主体 + callback_url: 回调地址 + + Returns: + 包含 task_id 和任务状态的字典 + """ + import aiohttp + + url = f"{self.base_url}/v1/general/advanced-custom-elements" + + payload = { + "element_name": element_name, + "element_description": element_description, + "reference_type": reference_type, + } + + # 修正 element_image_list 内部格式:refer_images 数组元素使用 image_url 而非 url + if element_image_list and isinstance(element_image_list, dict): + formatted_image_list = {"frontal_image": element_image_list.get("frontal_image", "")} + refer_images = element_image_list.get("refer_images", []) + if refer_images: + formatted_image_list["refer_images"] = [ + ( + {"image_url": img.get("url", img.get("image_url", ""))} + if isinstance(img, dict) + else {"image_url": img} + ) + for img in refer_images + ] + payload["element_image_list"] = formatted_image_list + # 修正 element_video_list 内部格式 + # element_video_list 是对象格式,不是数组 + # 正确格式: {"refer_videos": [{"video_url": "..."}]} + if element_video_list and isinstance(element_video_list, dict): + formatted_video_list = {} + + # 处理 refer_videos 数组 + refer_videos = element_video_list.get("refer_videos", []) + if refer_videos: + formatted_video_list["refer_videos"] = [ + ( + {"video_url": vid.get("url", vid.get("video_url", ""))} + if isinstance(vid, dict) + else {"video_url": vid} + ) + for vid in refer_videos + ] + + # 处理 frontal_video(如果提供了)- 转为 refer_videos 格式 + frontal_video = element_video_list.get("frontal_video", "") + if frontal_video: + if "refer_videos" not in formatted_video_list: + formatted_video_list["refer_videos"] = [] + formatted_video_list["refer_videos"].append({"video_url": frontal_video}) + + if formatted_video_list: + # API 要求 element_video_list 是对象格式 + payload["element_video_list"] = formatted_video_list + if element_voice_id: + payload["element_voice_id"] = element_voice_id + if callback_url: + payload["callback_url"] = callback_url + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI create_element response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + if isinstance(result_data, list) and len(result_data) > 0: + return result_data[0] if isinstance(result_data[0], dict) else {} + return result_data if isinstance(result_data, dict) else {} + + async def list_elements(self, **kwargs) -> list[dict[str, Any]]: + """查询主体列表 + + GET /v1/general/advanced-custom-elements + + KlingAI API 返回任务列表格式,每个任务包含 task_result.elements 数组。 + 需要从任务列表中提取所有主体信息。 + + Returns: + 主体列表,每个主体包含 element_id, element_name 等字段 + """ + import aiohttp + + url = f"{self.base_url}/v1/general/advanced-custom-elements" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI list_elements response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + + # KlingAI 返回任务列表,每个任务包含 task_result.elements + task_list = data.get("data", []) + elements = [] + + for task in task_list: + if isinstance(task, dict) and task.get("task_status") == "succeed": + task_result = task.get("task_result", {}) + if isinstance(task_result, dict): + element_list = task_result.get("elements", []) + if isinstance(element_list, list): + for element in element_list: + # 添加任务信息到主体数据中 + element["task_id"] = task.get("task_id") + element["task_status"] = task.get("task_status") + element["created_at"] = task.get("created_at") + element["updated_at"] = task.get("updated_at") + elements.append(element) + + return elements + + async def get_element_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询创建主体任务状态 + + GET /v1/general/advanced-custom-elements/{task_id} + + Args: + task_id: 创建任务的ID + """ + import aiohttp + + url = f"{self.base_url}/v1/general/advanced-custom-elements/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI get_element_task response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + return result_data if isinstance(result_data, dict) else {} + + async def get_element(self, element_id: str, **kwargs) -> dict[str, Any]: + """查询单个主体详情 + + GET /v1/general/advanced-custom-elements/{element_id} + + Args: + element_id: 主体ID + """ + import aiohttp + + url = f"{self.base_url}/v1/general/advanced-custom-elements/{element_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI get_element response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + return result_data if isinstance(result_data, dict) else {} + + async def delete_element(self, element_id: str, **kwargs) -> dict[str, Any]: + """删除主体 + + POST /v1/general/delete-elements + + Args: + element_id: 主体ID + """ + import aiohttp + + url = f"{self.base_url}/v1/general/delete-elements" + + payload = {"element_id": element_id} + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI delete_element response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + return result_data if isinstance(result_data, dict) else {} + + # ==================== 智能补全主体图 ==================== + + async def ai_multi_shot( + self, frontal_image: str, callback_url: str | None = None, **kwargs + ) -> dict[str, Any]: + """智能补全主体不同角度图片(AI Multi Shot) + + POST /v1/general/ai-multi-shot + + 通过主体正面图,自动推理出该主体其他角度图片。 + 每次可生成3组结果供选择,每次扣减0.5积分。 + + Args: + frontal_image: 主体正面参考图 URL + callback_url: 回调地址 + + Returns: + 包含生成结果的任务信息 + """ + import aiohttp + + url = f"{self.base_url}/v1/general/ai-multi-shot" + + payload = { + "element_frontal_image": frontal_image, + } + + if callback_url: + payload["callback_url"] = callback_url + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"KlingAI ai_multi_shot response: {data}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + return result_data if isinstance(result_data, dict) else {} + + async def get_ai_multi_shot_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询智能补全主体图任务状态 + + GET /v1/general/ai-multi-shot/{task_id} + + Args: + task_id: 任务ID + """ + import aiohttp + + url = f"{self.base_url}/v1/general/ai-multi-shot/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== Omni-Image 图像生成 ==================== + + async def generate_omni_image( + self, + prompt: str, + model: str = "kling-image-o1", + aspect_ratio: str = "9:16", + resolution: str = "1k", + result_type: str = "single", + n: int = 1, + element_list: list[dict[str, Any]] | None = None, + image_list: list[dict[str, Any]] | None = None, + callback_url: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """Omni-Image 图像生成 + + POST /v1/images/omni-image + + Args: + prompt: 正向提示词 + model: 模型名称,kling-image-o1 或 kling-v3-omni + aspect_ratio: 宽高比,可选 16:9/9:16/1:1/4:3/3:4 + resolution: 清晰度,1k/2k/4k + result_type: 结果类型,single/series + n: 生成图片数量(1-9) + element_list: 主体参考列表 + image_list: 参考图列表 + callback_url: 回调地址 + """ + import aiohttp + + url = f"{self.base_url}/v1/images/omni-image" + + payload: dict[str, Any] = { + "model_name": model, + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "result_type": result_type, + "n": n, + } + + if element_list: + payload["element_list"] = element_list + if image_list: + payload["image_list"] = image_list + if callback_url: + payload["callback_url"] = callback_url + + logger.info( + f"[KlingAI] omni-image 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" + ) + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"[KlingAI] omni-image 响应: {json.dumps(data, ensure_ascii=False)}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + if isinstance(result_data, list) and len(result_data) > 0: + return result_data[0] if isinstance(result_data[0], dict) else {} + return result_data if isinstance(result_data, dict) else {} + + async def get_omni_image_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询 Omni-Image 任务状态 + + GET /v1/images/omni-image/{task_id} + """ + import aiohttp + + url = f"{self.base_url}/v1/images/omni-image/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + # ==================== 文生图 ==================== + + async def generate_image( + self, + prompt: str, + model: str = "kling-v3", + aspect_ratio: str = "9:16", + negative_prompt: str | None = None, + callback_url: str | None = None, + **kwargs, + ) -> dict[str, Any]: + """文生图 + + POST /v1/images/generations + + Args: + prompt: 正向提示词 + model: 模型名称,kling-v3 + aspect_ratio: 宽高比,可选 16:9/9:16/1:1/4:3/3:4 + negative_prompt: 负向提示词 + callback_url: 回调地址 + """ + import aiohttp + + url = f"{self.base_url}/v1/images/generations" + + payload = { + "model_name": model, + "prompt": prompt, + "aspect_ratio": aspect_ratio, + } + + if negative_prompt: + payload["negative_prompt"] = negative_prompt + if callback_url: + payload["callback_url"] = callback_url + + # 记录请求参数(调试用) + logger.info( + f"[KlingAI] generate_image 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" + ) + + async with ( + aiohttp.ClientSession() as session, + session.post(url, json=payload, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + logger.info(f"[KlingAI] generate_image 响应: {json.dumps(data, ensure_ascii=False)}") + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + result_data = data.get("data", {}) + if isinstance(result_data, list) and len(result_data) > 0: + return result_data[0] if isinstance(result_data[0], dict) else {} + return result_data if isinstance(result_data, dict) else {} + + async def get_image_task(self, task_id: str, **kwargs) -> dict[str, Any]: + """查询文生图任务状态 + + GET /v1/images/generations/{task_id} + + Args: + task_id: 任务ID + """ + import aiohttp + + url = f"{self.base_url}/v1/images/generations/{task_id}" + + async with ( + aiohttp.ClientSession() as session, + session.get(url, headers=await self._get_headers()) as resp, + ): + data = await resp.json() + if data.get("code") != 0: + raise Exception(f"KlingAI API error: {data.get('message')}") + return data.get("data", {}) + + +# 导出 Provider 类 +__all__ = ["KlingAIProvider", "KlingAIConfig"] diff --git a/python-api/app/ai/providers/volcengine_provider.py b/python-api/app/ai/providers/volcengine_provider.py new file mode 100644 index 0000000..9465698 --- /dev/null +++ b/python-api/app/ai/providers/volcengine_provider.py @@ -0,0 +1,464 @@ +""" +火山方舟官方 SDK Provider +========================== + +基于火山方舟官方 Python SDK 实现,支持: +- 文本生成 (Chat Completions) +- 流式输出 +- 图片生成 +- 向量化 +- 深度思考 +- 工具调用 + +安装依赖: + pip install 'volcengine-python-sdk[ark]' + +文档: + https://www.volcengine.com/docs/82379 +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import AsyncIterator + +from app.ai.providers.base import ( + GenerationResult, + LLMProvider, + ModelHealth, + ProviderError, +) + +logger = logging.getLogger(__name__) + +# 尝试导入火山方舟 SDK +try: + from volcenginesdkarkruntime import Ark + + VOLCENGINE_SDK_AVAILABLE = True +except ImportError: + VOLCENGINE_SDK_AVAILABLE = False + logger.warning("火山方舟 SDK 未安装,请运行: pip install 'volcengine-python-sdk[ark]'") + + +class VolcengineProvider(LLMProvider): + """ + 火山方舟官方 SDK Provider + + 支持多模态能力: + - 文本对话 (Chat Completions) + - 图片生成 (Image Generation) + - 向量化 (Embeddings) + - 深度思考 (Reasoning) + """ + + provider_id = "volcengine" + provider_name = "火山方舟" + + # 默认配置 + DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" + DEFAULT_TIMEOUT = 1800 # 秒,方舟推荐 1800 秒以上 + + # 模型 ID 映射(从配置文件加载) + PRESET_MODELS: dict[str, str] = {} + + @classmethod + def load_models_from_config(cls, models: list[dict]): + """ + 从配置文件加载模型列表 + + Args: + models: 模型列表,每个模型包含 model_name 字段 + """ + cls.PRESET_MODELS = {} + for model in models: + model_id = model.get("model_name") + model_alias = model.get("id") + if model_id and model_alias: + cls.PRESET_MODELS[model_alias] = model_id + + # 确保至少有一个默认模型 + if not cls.PRESET_MODELS: + cls.PRESET_MODELS = { + "doubao-seed-2-0-lite": "doubao-seed-2-0-lite-260215", + } + + logger.info(f"已加载 {len(cls.PRESET_MODELS)} 个模型: {list(cls.PRESET_MODELS.keys())}") + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + timeout: int = DEFAULT_TIMEOUT, + default_model: str | None = None, + **kwargs, + ): + """ + 初始化火山方舟 Provider + + Args: + api_key: API Key,从火山方舟控制台获取 + base_url: API 基础地址,默认北京节点 + timeout: 请求超时时间(秒) + default_model: 默认模型(Model ID) + """ + super().__init__(api_key, base_url, **kwargs) + + if not VOLCENGINE_SDK_AVAILABLE: + raise ProviderError( + "火山方舟 SDK 未安装,请运行: pip install 'volcengine-python-sdk[ark]'", + provider_id=self.provider_id, + ) + + if not self.api_key: + raise ProviderError("火山方舟 API Key 未配置", provider_id=self.provider_id) + + self.timeout = timeout + # 使用模型 ID 映射(自动映射) + if default_model: + self.default_model = self.PRESET_MODELS.get(default_model, default_model) + elif self.PRESET_MODELS: + # 兜底:使用 doubao-seed-2-0-lite 或第一个可用的模型 + self.default_model = self.PRESET_MODELS.get( + "doubao-seed-2-0-lite", list(self.PRESET_MODELS.values())[0] + ) + else: + # 兜底:使用一个默认模型ID(如果用户未配置任何模型) + self.default_model = "doubao-seed-2-0-lite-260215" + + self.client = self._create_client() + + def _create_client(self) -> Ark: + """创建火山方舟客户端""" + return Ark( + api_key=self.api_key, + base_url=self.base_url or self.DEFAULT_BASE_URL, + timeout=self.timeout, + ) + + async def generate( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + system_prompt: str | None = None, + **kwargs, + ) -> GenerationResult: + """ + 同步生成文本 + + Args: + prompt: 用户提示词 + model: 模型 ID(如 doubao-seed-2-0-pro-260215) + temperature: 随机性 (0-2) + max_tokens: 最大生成 token 数 + system_prompt: 系统提示词(可选) + **kwargs: 额外参数(如 enable_thinking 启用深度思考) + + Returns: + GenerationResult: 生成结果 + """ + try: + # 构建消息 + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + # 映射模型名称到模型 ID + model_id = self.PRESET_MODELS.get(model, model) if model else self.default_model + + # 构建请求参数 + request_params = { + "model": model_id, + "messages": messages, + "temperature": temperature, + } + + if max_tokens: + request_params["max_tokens"] = max_tokens + + # 额外参数(如深度思考) + if "enable_thinking" in kwargs: + request_params["extra_body"] = {"enable_thinking": kwargs["enable_thinking"]} + + # 调用 API + completion = self.client.chat.completions.create(**request_params) + + # 解析结果 + content = completion.choices[0].message.content or "" + usage = None + if completion.usage: + usage = { + "prompt_tokens": completion.usage.prompt_tokens, + "completion_tokens": completion.usage.completion_tokens, + "total_tokens": completion.usage.total_tokens, + } + + return GenerationResult( + content=content, + usage=usage, + model=completion.model or model or self.default_model, + ) + + except Exception as e: + raise ProviderError( + f"火山方舟生成失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def generate_stream( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = None, + system_prompt: str | None = None, + **kwargs, + ) -> AsyncIterator[str]: + """ + 流式生成文本 + + Args: + prompt: 用户提示词 + model: 模型名称 + temperature: 随机性 + max_tokens: 最大 token 数 + system_prompt: 系统提示词(可选) + **kwargs: 额外参数 + + Yields: + str: 生成的文本片段 + """ + try: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + model_id = self.PRESET_MODELS.get(model, model) if model else self.default_model + + request_params = { + "model": model_id, + "messages": messages, + "temperature": temperature, + "stream": True, + } + + if max_tokens: + request_params["max_tokens"] = max_tokens + + stream = self.client.chat.completions.create(**request_params) + + for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + yield chunk.choices[0].delta.content + + except Exception as e: + raise ProviderError( + f"火山方舟流式生成失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def generate_stream_with_progress( + self, + prompt: str, + model: str | None = None, + temperature: float = 0.7, + max_tokens: int | None = 8000, + system_prompt: str | None = None, + **kwargs, + ) -> AsyncIterator[dict]: + """ + 流式生成文本,带进度信息 + + Args: + prompt: 用户提示词 + model: 模型名称 + temperature: 随机性 + max_tokens: 最大 token 数 + system_prompt: 系统提示词(可选) + + Yields: + dict: { + "type": "chunk" | "usage", + "content": str, # 文本片段(type=chunk时) + "total_tokens": int, # 累计token数(type=chunk时) + "prompt_tokens": int, # 提示词token数(type=usage时) + "completion_tokens": int, # 生成token数(type=usage时) + } + """ + try: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + model_id = self.PRESET_MODELS.get(model, model) if model else self.default_model + + request_params = { + "model": model_id, + "messages": messages, + "temperature": temperature, + "stream": True, + } + + if max_tokens: + request_params["max_tokens"] = max_tokens + + # 流式调用 + stream = self.client.chat.completions.create(**request_params) + + total_chars = 0 + prompt_tokens = 0 + completion_tokens = 0 + + for chunk in stream: + # 获取文本内容 + if chunk.choices and chunk.choices[0].delta.content: + content = chunk.choices[0].delta.content + total_chars += len(content) + yield { + "type": "chunk", + "content": content, + "total_chars": total_chars, + } + + # 获取使用统计(最后一个chunk) + if chunk.usage: + prompt_tokens = chunk.usage.prompt_tokens + completion_tokens = chunk.usage.completion_tokens + + # 发送最终统计 + yield { + "type": "usage", + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + } + + except Exception as e: + raise ProviderError( + f"火山方舟流式生成失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def generate_image( + self, prompt: str, model: str | None = None, size: str = "1024x1024", **kwargs + ) -> dict: + """ + 生成图片(Seedream 系列) + + Args: + prompt: 图片提示词 + model: 图片模型 ID + size: 图片尺寸 + + Returns: + dict: 包含图片 URL 或 base64 数据 + """ + try: + # 图片生成需要单独的图片模型,不在当前配置中 + # 如需使用,请在模型广场开通 doubao-seed-1.6 并配置 + image_model = model or "doubao-seed-1.6-flash-250828" + response = self.client.images.generate( + model=image_model, prompt=prompt, size=size, **kwargs + ) + + # 解析图片结果 + images = [] + for img in response.data: + images.append( + { + "url": img.url, + "b64_json": img.b64_json, + "revised_prompt": img.revised_prompt, + } + ) + + return { + "images": images, + "model": response.model, + } + + except Exception as e: + raise ProviderError( + f"火山方舟图片生成失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def create_embeddings(self, texts: list[str], model: str | None = None, **kwargs) -> dict: + """ + 文本向量化 + + Args: + texts: 文本列表 + model: 向量化模型 + + Returns: + dict: 包含向量化结果 + """ + try: + response = self.client.embeddings.create( + model=model or "doubao-embedding-1.5", input=texts, **kwargs + ) + + embeddings = [] + for item in response.data: + embeddings.append( + { + "index": item.index, + "embedding": item.embedding, + } + ) + + return { + "embeddings": embeddings, + "model": response.model, + "usage": { + "prompt_tokens": response.usage.prompt_tokens, + "total_tokens": response.usage.total_tokens, + }, + } + + except Exception as e: + raise ProviderError( + f"火山方舟向量化失败: {str(e)}", provider_id=self.provider_id, original_error=e + ) + + async def health_check(self, model: str | None = None) -> ModelHealth: + """健康检查""" + start_time = time.time() + test_model = model or self.default_model + + try: + response = self.client.chat.completions.create( + model=test_model, + messages=[{"role": "user", "content": "Hi"}], + max_tokens=5, + ) + + response_time = (time.time() - start_time) * 1000 + + return ModelHealth( + id=test_model, + name=f"火山方舟 {test_model}", + is_available=True, + response_time=response_time, + last_error=None, + ) + + except Exception as e: + return ModelHealth( + id=test_model, + name=f"火山方舟 {test_model}", + is_available=False, + response_time=(time.time() - start_time) * 1000, + last_error=str(e), + ) + + @property + def available_models(self) -> list[str]: + """返回可用模型列表(与 ai_models.yaml 配置保持一致)""" + return [ + "doubao-seed-2-0-pro", + "deepseek-v3-2", + "doubao-seed-2-0-lite", + "doubao-lite-32k", + ] diff --git a/python-api/app/api/__init__.py b/python-api/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/api/deps.py b/python-api/app/api/deps.py new file mode 100644 index 0000000..ab3c9b2 --- /dev/null +++ b/python-api/app/api/deps.py @@ -0,0 +1,83 @@ +""" +依赖注入工具 +============ +""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.core.security import verify_token +from app.db.session import get_db as db_session +from app.models.user import User + +settings = get_settings() +security = HTTPBearer(auto_error=False) + + +# 数据库依赖 +async def get_db() -> AsyncSession: + """获取数据库 Session""" + async for session in db_session(): + yield session + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + """ + 获取当前登录用户 + + 从 Authorization Header 中提取 JWT Token 并验证 + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="缺少认证信息", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + payload = verify_token(token) + + if payload is None or payload.get("sub") is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证信息", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = payload.get("sub") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + db=Depends(get_db), +) -> User | None: + """ + 获取当前登录用户(可选,未登录返回 None) + """ + if credentials is None: + return None + + try: + return await get_current_user(credentials, db) + except HTTPException: + return None diff --git a/python-api/app/api/v1/__init__.py b/python-api/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/api/v1/ai_models.py b/python-api/app/api/v1/ai_models.py new file mode 100644 index 0000000..0e07b69 --- /dev/null +++ b/python-api/app/api/v1/ai_models.py @@ -0,0 +1,552 @@ +""" +AI 模型管理与生成 API +===================== + +提供模型列表查询、文本生成、脚本生成、润色等功能。 + +模型配置存储在 config/ai_models.yaml,支持热重载。 +""" + +import logging + +logger = logging.getLogger(__name__) + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.ai.model_router import get_model_router +from app.core.config_loader import get_config_loader +from app.schemas.common import ApiResponse, success_response +from app.services.ai_response_utils import ( + safe_parse_ai_json_response, + validate_and_normalize_shots, + validate_shots_structure, +) + +router = APIRouter() + + +# ============ 请求/响应 Schema ============ + + +class PlatformResponse(BaseModel): + """平台响应""" + + id: str + name: str + provider: str + + +class ModelResponse(BaseModel): + """模型响应""" + + id: str + platform_id: str + model_name: str + display_name: str + capabilities: list[str] + default_params: dict + is_enabled: bool + full_model_id: str + + +class GenerateRequest(BaseModel): + """生成请求""" + + prompt: str = Field(..., description="提示词") + model_id: str | None = Field(None, description="指定模型 ID") + task_type: str | None = Field( + None, description="任务类型,用于自动选模型: script/polish" + ) + temperature: float | None = Field(None, description="随机性 (0-2)") + max_tokens: int | None = Field(None, description="最大生成长度") + + +class GenerateResponse(BaseModel): + """生成响应""" + + content: str + model: str + usage: dict | None + + +class HealthResponse(BaseModel): + """健康检查响应""" + + status: str + total_models: int + available_models: int + models: list[dict] + + +# ============ API 路由 ============ + + +@router.get("/platforms", response_model=ApiResponse[list[PlatformResponse]]) +async def list_platforms(): + """获取所有平台列表""" + config_loader = get_config_loader() + platforms = config_loader.get_all_platforms() + + return success_response( + data=[ + PlatformResponse( + id=p.id, + name=p.name, + provider=p.provider, + ) + for p in platforms + ] + ) + + +@router.get("/models", response_model=ApiResponse[list[ModelResponse]]) +async def list_models(capability: str | None = None): + """获取模型列表 + + Args: + capability: 按能力过滤,如 script、polish、chat + """ + router = await get_model_router() + models = router.list_models(capability=capability) + + return success_response( + data=[ + ModelResponse( + id=m["id"], + platform_id=m["platform_id"], + model_name=m["model_name"], + display_name=m["display_name"], + capabilities=m["capabilities"], + default_params=m["default_params"], + is_enabled=True, # 列表中的都是启用的 + full_model_id=f"{m['platform_id']}/{m['id']}", + ) + for m in models + ] + ) + + +@router.post("/generate", response_model=ApiResponse[GenerateResponse]) +async def generate_text(data: GenerateRequest): + """文本生成(自动路由到对应平台)""" + router = await get_model_router() + + try: + result = await router.generate( + prompt=data.prompt, + model_id=data.model_id, + task_type=data.task_type, + temperature=data.temperature, + max_tokens=data.max_tokens, + ) + + return success_response( + data=GenerateResponse( + content=result.content, + model=result.model, + usage=result.usage, + ) + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/health", response_model=ApiResponse[HealthResponse]) +async def health_check(model_id: str | None = None): + """检查模型健康状态""" + router = await get_model_router() + + health_results = await router.health_check(model_id) + + models_status = [] + available_count = 0 + + for mid, health in health_results.items(): + models_status.append( + { + "id": mid, + "name": health.name, + "is_available": health.is_available, + "response_time": health.response_time, + "last_error": health.last_error, + } + ) + if health.is_available: + available_count += 1 + + return success_response( + data={ + "status": "healthy" if available_count > 0 else "unhealthy", + "total_models": len(models_status), + "available_models": available_count, + "models": models_status, + } + ) + + +@router.get("/platforms/{platform_id}/test", response_model=ApiResponse[dict]) +async def test_platform_connection(platform_id: str): + """测试平台连接""" + from app.ai.model_router import PlatformInstance + + config_loader = get_config_loader() + platform = config_loader.get_platform(platform_id) + + if not platform: + raise HTTPException(status_code=404, detail="平台不存在") + + try: + # PlatformInstance 自动从 Settings 读取 API Key + instance = PlatformInstance( + { + "id": platform.id, + "name": platform.name, + "provider": platform.provider, + "base_url": platform.base_url, + } + ) + + # 尝试调用 + result = await instance.provider.health_check() + + return success_response( + data={ + "platform_id": platform_id, + "success": result.is_available, + "response_time": result.response_time, + "message": "连接成功" if result.is_available else result.last_error, + } + ) + except Exception as e: + return success_response( + data={ + "platform_id": platform_id, + "success": False, + "error": str(e), + } + ) + + +@router.post("/reload", response_model=ApiResponse[dict]) +async def reload_config(): + """重新加载配置文件""" + router = await get_model_router() + reloaded = router.reload_config() + + if reloaded: + return success_response(data={"reloaded": True}, message="配置已重新加载") + else: + return success_response(data={"reloaded": False}, message="配置文件无变化") + + +# ============================================================================= +# Prompt 模板 API +# ============================================================================= + +from app.ai.prompts import ( + SCRIPT_TYPES, + VIDEO_STYLES, + PolishPromptBuilder, + ScriptPromptBuilder, +) + + +class PromptTemplatesResponse(BaseModel): + """Prompt 模板配置响应""" + + script_types: list[dict] + video_styles: list[dict] + tones: list[str] + + +class ScriptGenerateRequest(BaseModel): + """脚本生成请求""" + + topic: str = Field(..., description="脚本主题", example="水电改造的3个致命错误") + duration: int = Field(30, ge=15, le=120, description="视频时长(秒)") + script_type: str = Field("干货型", description="脚本类型") + video_style: str = Field("口播", description="视频风格") + tone: str | None = Field(None, description="语气风格") + requirements: str | None = Field(None, description="额外要求") + model_id: str | None = Field(None, description="指定模型ID,默认使用系统默认模型") + + +class ScriptGenerateResponse(BaseModel): + """脚本生成响应 - 针对前端展示优化""" + + success: bool + script: list[ + dict | None + ] # 镜头列表,包含 shot_number, type, scene/prompt, voiceover, duration, word_count + total_duration: int | None # 预计总时长(秒) + target_duration: int # 目标时长(秒) + total_word_count: int | None # 总字数(供前端展示) + segment_count: int | None # 分镜数量(供前端展示) + empty_shot_count: int | None # 空镜数量(供前端展示) + script_type: str + model: str + usage: dict | None + error: str | None + raw_content: str | None + + +class PolishRequest(BaseModel): + """润色请求""" + + content: str = Field(..., description="需要润色的内容") + polish_type: str = Field("voiceover", description="润色类型:scene/voiceover") + model_id: str | None = Field(None, description="指定模型ID") + + +class PolishResponse(BaseModel): + """润色响应""" + + success: bool + original: str + polished: str | None + polish_type: str + model: str + usage: dict | None + + +@router.get("/prompts/templates", response_model=ApiResponse[PromptTemplatesResponse]) +async def get_prompt_templates(): + """ + 获取所有可用的 Prompt 模板配置 + + 包括脚本类型、视频风格、语气风格等选项。 + """ + return success_response( + data={ + "script_types": [ + { + "id": key, + "name": value["name"], + "description": value["description"], + "key_points": value["key_points"], + } + for key, value in SCRIPT_TYPES.items() + if key != "default" + ], + "video_styles": [ + { + "id": key, + "name": value["name"], + "description": value["description"], + } + for key, value in VIDEO_STYLES.items() + ], + "tones": ["专业", "亲和", "幽默", "严肃", "激情"], + } + ) + + +@router.post("/prompts/build", response_model=ApiResponse[dict]) +async def build_system_prompt( + duration: int = 30, + script_type: str = "干货型", + video_style: str = "口播", + tone: str | None = None, +): + """ + 构建系统 Prompt(用于调试和预览) + + 返回构建好的系统 Prompt,可用于前端预览或调试。 + """ + builder = ScriptPromptBuilder() + prompt = builder.build( + duration=duration, + script_type=script_type, + video_style=video_style, + industry="家装", + tone=tone, + ) + + return success_response( + data={ + "system_prompt": prompt, + "length": len(prompt), + "parameters": { + "duration": duration, + "script_type": script_type, + "video_style": video_style, + "tone": tone, + }, + } + ) + + +@router.post("/scripts/generate", response_model=ApiResponse[ScriptGenerateResponse]) +async def generate_script(data: ScriptGenerateRequest): + """ + 生成家装行业短视频脚本 + + 使用专业的 Prompt 模板生成包含分镜+空镜的混合脚本。 + 针对前端展示优化,返回分镜数、空镜数、总字数等统计信息。 + """ + router = await get_model_router() + + # 构建系统 Prompt + builder = ScriptPromptBuilder() + system_prompt = builder.build( + duration=data.duration, + script_type=data.script_type, + video_style=data.video_style, + industry="家装", + tone=data.requirements, + custom_requirements=data.requirements, + ) + + # 构建用户输入 + user_prompt = f"""主题是"{data.topic}" + +要求: +1. 严格按照时长要求控制 +2. 每个镜头的配音字数必须匹配时长(4-5字/秒) +3. 空镜必须有画外音,不能为空 +4. 只返回JSON数组,不要有其他文字""" + + full_prompt = f"{system_prompt}\n\n【用户输入】\n{user_prompt}\n\n请生成脚本,只返回JSON数组:" + + # 调用模型 + try: + result = await router.generate( + prompt=full_prompt, + model_id=data.model_id, + task_type="script", + temperature=0.7, + max_tokens=2500, + ) + + # 安全地解析 JSON 响应 + success_parsed, parsed_data, error_msg = safe_parse_ai_json_response( + result.content + ) + + if not success_parsed: + logger.error(f"AI 响应解析失败: {error_msg}") + return success_response( + data={ + "success": False, + "script": None, + "total_duration": None, + "target_duration": data.duration, + "total_word_count": None, + "segment_count": None, + "empty_shot_count": None, + "script_type": data.script_type, + "model": result.model, + "usage": result.usage, + "error": error_msg or "JSON解析失败", + "raw_content": result.content, + } + ) + + # 验证并标准化分镜数据 + try: + script = validate_and_normalize_shots(parsed_data) + except Exception as e: + logger.error(f"分镜数据标准化失败: {e}") + return success_response( + data={ + "success": False, + "script": None, + "total_duration": None, + "target_duration": data.duration, + "total_word_count": None, + "segment_count": None, + "empty_shot_count": None, + "script_type": data.script_type, + "model": result.model, + "usage": result.usage, + "error": f"分镜数据格式错误: {e}", + "raw_content": result.content, + } + ) + + # 验证分镜结构 + is_valid, validation_errors = validate_shots_structure(script) + if not is_valid: + logger.warning(f"分镜结构验证失败: {validation_errors}") + # 继续处理,但记录警告 + + # 计算统计信息(供前端展示) + total_duration = sum( + int(shot.get("duration", "5s").rstrip("s秒")) + for shot in script + if isinstance(shot, dict) + ) + total_word_count = sum( + len(shot.get("voiceover", "")) for shot in script if isinstance(shot, dict) + ) + segment_count = sum( + 1 + for shot in script + if isinstance(shot, dict) and shot.get("type") == "segment" + ) + empty_shot_count = sum( + 1 + for shot in script + if isinstance(shot, dict) and shot.get("type") == "empty_shot" + ) + + return success_response( + data={ + "success": True, + "script": script, + "total_duration": total_duration, + "target_duration": data.duration, + "total_word_count": total_word_count, + "segment_count": segment_count, + "empty_shot_count": empty_shot_count, + "script_type": data.script_type, + "model": result.model, + "usage": result.usage, + "error": None, + "raw_content": None, + } + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") + + +@router.post("/scripts/polish", response_model=ApiResponse[PolishResponse]) +async def polish_script_content(data: PolishRequest): + """ + 润色脚本内容 + + 对场景描述或口播文案进行专业润色。 + """ + router = await get_model_router() + + # 构建润色 Prompt + builder = PolishPromptBuilder() + system_prompt = builder.build(data.polish_type) + + full_prompt = f"{system_prompt}\n\n【待润色内容】\n{data.content}\n\n请润色:" + + # 调用模型 + try: + result = await router.generate( + prompt=full_prompt, + model_id=data.model_id, + task_type="polish", + temperature=0.6, + max_tokens=1000, + ) + + return success_response( + data={ + "success": True, + "original": data.content, + "polished": result.content, + "polish_type": data.polish_type, + "model": result.model, + "usage": result.usage, + } + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"润色失败: {str(e)}") diff --git a/python-api/app/api/v1/auth.py b/python-api/app/api/v1/auth.py new file mode 100644 index 0000000..eefdd29 --- /dev/null +++ b/python-api/app/api/v1/auth.py @@ -0,0 +1,83 @@ +""" +认证模块 API +============ + +采用"手机号 + JWT"的认证方案。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.api.deps import get_current_user +from app.core.security import create_access_token +from app.crud.user import user as user_crud +from app.db.session import AsyncSession, get_db +from app.models.user import User +from app.schemas.auth import LoginResponse, MobileLoginRequest +from app.schemas.common import ApiResponse, success_response + +router = APIRouter() + + +@router.post("/login", response_model=ApiResponse[LoginResponse]) +async def login( + request: MobileLoginRequest, + db: AsyncSession = Depends(get_db), +): + """ + 手机号登录/注册 + + - 如果手机号已存在,返回对应用户 + - 如果不存在,自动创建新用户 + - 返回 JWT Token 用于后续认证 + """ + # 获取或创建用户 + user_obj = await user_crud.get_or_create_by_mobile( + db, + mobile=request.mobile, + nickname=request.nickname, + ) + + # 生成 JWT Token + token = create_access_token(data={"sub": user_obj.id, "mobile": user_obj.mobile}) + + return success_response( + data=LoginResponse( + token=token, + user={ + "id": user_obj.id, + "nickname": user_obj.nickname or "", + "avatar": user_obj.avatar_url or "", + }, + ), + message="登录成功", + ) + + +@router.get("/me", response_model=ApiResponse[dict]) +async def get_me( + current_user: User = Depends(get_current_user), +): + """获取当前登录用户信息""" + return success_response( + data={ + "id": current_user.id, + "mobile": current_user.mobile, + "nickname": current_user.nickname, + "avatar": current_user.avatar_url, + "createdAt": current_user.created_at.isoformat() if current_user.created_at else None, + } + ) + + +@router.post("/refresh", response_model=ApiResponse[dict]) +async def refresh_token( + current_user: User = Depends(get_current_user), +): + """刷新 JWT Token""" + new_token = create_access_token(data={"sub": current_user.id, "mobile": current_user.mobile}) + return success_response( + data={"token": new_token}, + message="Token 刷新成功", + ) diff --git a/python-api/app/api/v1/avatar.py b/python-api/app/api/v1/avatar.py new file mode 100644 index 0000000..b7f1f28 --- /dev/null +++ b/python-api/app/api/v1/avatar.py @@ -0,0 +1,560 @@ +""" +Avatar 形象克隆模块 +================== + +串行流程: +1. 使用上传的视频创建 KlingAI 自定义音色 (custom-voices) +2. 轮询等待音色生成完成,获取 voice_id +3. 使用同一视频 + voice_id 创建 KlingAI 主体 (advanced-custom-elements) +4. 轮询等待主体生成完成,获取 provider_element_id +5. 返回统一的 AvatarItem + +异步架构: +- POST /avatar/clone 只负责注册到 Async Engine(纯 Redis,无 DB),立即返回 task_id +- 真正的轮询由 Async Engine Scheduler 在后台执行 +- 前端通过 SSE 或轮询 GET /avatar/tasks/{task_id} 查询进度 + +数据策略: +- 形象克隆数据只保存在前端本地,后端不持久化到数据库 +- 任务运行时的中间状态全部存储在 Redis 中(TTL 24h) + +错误提示策略: +- custom-voice 失败:提示"有声的人物视频"相关原因 +- element 失败:提示视频内容/质量不符合主体创建要求 +- 超时:标记为 timeout,支持重试 +""" + +import asyncio +import contextlib +import json +import logging +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, ConfigDict, Field + +from app.ai.providers.klingai_provider import KlingAIProvider +from app.api.deps import get_current_user +from app.config import get_settings +from app.core.redis_client import get_redis_client +from app.scheduler.registry import JobRegistry +from app.schemas.common import ApiResponse, success_response +from app.schemas.enums import AvatarCloneStatus + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _get_kling_provider() -> KlingAIProvider: + settings = get_settings() + return KlingAIProvider( + config={ + "access_key": settings.KLINGAI_ACCESS_KEY or "", + "secret_key": settings.KLINGAI_SECRET_KEY or "", + } + ) + + +async def _get_avatar_state(redis, job_id: str) -> dict | None: + """从 Redis 读取 avatar 任务完整状态""" + data = await redis.hgetall(f"job:{job_id}") + if not data: + return None + + # 解析 JSON 字段 + for key in ("result", "params"): + if key in data and data[key]: + with contextlib.suppress(json.JSONDecodeError): + data[key] = json.loads(data[key]) + return data + + +class CloneAvatarRequest(BaseModel): + """创建形象克隆请求""" + + name: str = Field(..., min_length=1, max_length=20, description="形象名称") + video_url: str = Field(description="人物视频 URL") + + +class CloneAvatarResponse(BaseModel): + """创建形象克隆响应""" + + task_id: str = Field(..., description="任务 ID(用于 SSE/轮询跟踪进度)") + status: str = Field("pending", description="初始状态") + + +class AvatarTaskStatusResponse(BaseModel): + """任务状态查询响应""" + + task_id: str + status: str = Field(..., description="当前状态") + fail_reason: str | None = Field(None, description="失败原因") + voice_id: str | None = Field(None, description="已生成的音色 ID") + human_id: int | None = Field(None, description="已生成的主体 ID") + trial_url: str | None = Field(None, description="试听 URL") + video_url: str = Field(..., description="原始视频 URL") + name: str = Field(..., description="形象名称") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +class AvatarItem(BaseModel): + """形象库列表项""" + + model_config = ConfigDict(from_attributes=True) + + id: str = Field(..., description="形象唯一标识") + name: str = Field(..., description="展示名称") + voice_id: str = Field(..., description="Kling 自定义音色 ID") + human_id: int = Field(..., description="数字人主体 ID") + video_url: str = Field(description="原始人物视频 URL") + trial_url: str | None = Field(None, description="音色试听 URL") + record_time: str = Field(description="创建时间 ISO 字符串") + + +class UpdateAvatarNameRequest(BaseModel): + """更新形象名称请求""" + + name: str = Field(..., min_length=1, max_length=20, description="新形象名称") + + +# ============================================================ +# API 路由 +# ============================================================ + + +@router.post("/avatar/clone", response_model=ApiResponse[CloneAvatarResponse]) +async def clone_avatar( + data: CloneAvatarRequest, + current_user: dict = Depends(get_current_user), +): + """ + 提交形象克隆任务 + + 立即返回 task_id,前端通过 SSE 或轮询跟踪进度。 + 实际串行流程由 Async Engine Scheduler 异步执行。 + 任务状态纯 Redis 存储,不写入数据库。 + """ + user_id = str(current_user.id) + name = data.name.strip() + video_url = data.video_url.strip() + + # 生成 task_id + task_id = f"avt_{uuid.uuid4().hex[:16]}" + now = datetime.now(UTC) + + # 写入 Redis,供 Async Engine 调度(同时存储 avatar 初始状态) + redis = get_redis_client() + registry = JobRegistry(redis) + await registry.create(task_id, "avatar_clone", user_id) + await registry.update( + task_id, + status="running", + progress=5, + message="开始形象克隆...", + completed=0, + total=1, + params={ + "avatar_id": task_id, + "name": name, + "video_url": video_url, + "user_id": user_id, + }, + # 存储 avatar 状态字段(供 API 查询) + avatar_status=AvatarCloneStatus.PENDING.value, + avatar_name=name, + avatar_video_url=video_url, + voice_id="", + provider_element_id="", + provider_voice_job_id="", + provider_element_job_id="", + trial_url="", + fail_reason="", + created_at=now.isoformat(), + updated_at=now.isoformat(), + ) + await registry.add_running(task_id) + + return success_response(data=CloneAvatarResponse(task_id=task_id, status="pending")) + + +@router.get("/avatar/tasks/{task_id}", response_model=ApiResponse[AvatarTaskStatusResponse]) +async def get_avatar_task_status( + task_id: str, + current_user: dict = Depends(get_current_user), +): + """查询形象克隆任务状态(从 Redis 读取)""" + redis = get_redis_client() + state = await _get_avatar_state(redis, task_id) + if not state: + raise HTTPException(status_code=404, detail="任务不存在") + + # 权限检查 + params = state.get("params", {}) if isinstance(state.get("params"), dict) else {} + if params.get("user_id") != str(current_user.id): + raise HTTPException(status_code=404, detail="任务不存在") + + def _dt(key: str) -> datetime: + raw = state.get(key, "") + if raw: + try: + return datetime.fromisoformat(raw) + except ValueError: + pass + return datetime.now(UTC) + + def _int(key: str) -> int | None: + raw = state.get(key, "") + if raw: + try: + return int(raw) + except ValueError: + pass + return None + + return success_response( + data=AvatarTaskStatusResponse( + task_id=task_id, + status=state.get("avatar_status", state.get("status", "unknown")), + fail_reason=state.get("fail_reason") or None, + voice_id=state.get("voice_id") or None, + human_id=_int("provider_element_id"), + trial_url=state.get("trial_url") or None, + video_url=params.get("video_url", ""), + name=params.get("name", ""), + created_at=_dt("created_at"), + updated_at=_dt("updated_at"), + ) + ) + + +@router.get("/avatar/clone/stream") +async def sse_avatar_clone( + task_id: str = Query(..., alias="task_id", description="任务 ID"), + current_user: dict = Depends(get_current_user), +): + """ + SSE 流:实时推送形象克隆任务状态 + + 前端连接后,每 3 秒推送一次状态,直到任务结束(succeed / failed / timeout)。 + """ + user_id = str(current_user.id) + + async def event_stream(): + for _ in range(400): # 最多 20 分钟(400 * 3s) + redis = get_redis_client() + state = await _get_avatar_state(redis, task_id) + + if not state: + payload = json.dumps( + {"status": "error", "fail_reason": "任务不存在或无权限"}, ensure_ascii=False + ) + yield f"event: error\ndata: {payload}\n\n" + break + + # 权限检查 + params = state.get("params", {}) if isinstance(state.get("params"), dict) else {} + if params.get("user_id") != user_id: + payload = json.dumps( + {"status": "error", "fail_reason": "任务不存在或无权限"}, ensure_ascii=False + ) + yield f"event: error\ndata: {payload}\n\n" + break + + avatar_status = state.get("avatar_status", state.get("status", "unknown")) + + payload = json.dumps( + { + "task_id": task_id, + "status": avatar_status, + "fail_reason": state.get("fail_reason") or None, + "voice_id": state.get("voice_id") or None, + "provider_element_id": state.get("provider_element_id") or None, + "trial_url": state.get("trial_url") or None, + "video_url": params.get("video_url", ""), + "name": params.get("name", ""), + "created_at": state.get("created_at", ""), + "updated_at": state.get("updated_at", ""), + }, + ensure_ascii=False, + ) + yield f"data: {payload}\n\n" + + if avatar_status in ( + AvatarCloneStatus.SUCCEED, + AvatarCloneStatus.VOICE_FAILED, + AvatarCloneStatus.ELEMENT_FAILED, + AvatarCloneStatus.TIMEOUT, + ): + break + + await asyncio.sleep(3) + else: + # 达到最大轮询次数,推送超时事件 + payload = json.dumps( + {"status": "timeout", "fail_reason": "连接超时,请通过轮询接口继续跟踪"}, + ensure_ascii=False, + ) + yield f"event: timeout\ndata: {payload}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + +@router.post("/avatar/tasks/{task_id}/retry", response_model=ApiResponse[dict]) +async def retry_avatar_task( + task_id: str, + current_user: dict = Depends(get_current_user), +): + """ + 重试失败或超时的形象克隆任务 + + 仅允许对 voice_failed / element_failed / timeout 状态的任务重试。 + 重试时会重置状态为 pending 并重新注册到 Async Engine。 + """ + redis = get_redis_client() + state = await _get_avatar_state(redis, task_id) + if not state: + raise HTTPException(status_code=404, detail="任务不存在") + + params = state.get("params", {}) if isinstance(state.get("params"), dict) else {} + if params.get("user_id") != str(current_user.id): + raise HTTPException(status_code=404, detail="任务不存在") + + avatar_status = state.get("avatar_status", state.get("status", "")) + if avatar_status not in ( + AvatarCloneStatus.VOICE_FAILED.value, + AvatarCloneStatus.ELEMENT_FAILED.value, + AvatarCloneStatus.TIMEOUT.value, + ): + raise HTTPException(status_code=400, detail=f"当前状态 {avatar_status} 不支持重试") + + # 重置状态 + registry = JobRegistry(redis) + now = datetime.now(UTC).isoformat() + await registry.update( + task_id, + status="running", + avatar_status=AvatarCloneStatus.PENDING, + fail_reason="", + voice_id="", + provider_element_id="", + provider_voice_job_id="", + provider_element_job_id="", + trial_url="", + updated_at=now, + ) + await registry.add_running(task_id) + + return success_response(data={"task_id": task_id, "status": "pending"}) + + +@router.delete("/avatar/{avatar_id}", response_model=ApiResponse[dict]) +async def delete_avatar( + avatar_id: str, + voice_id: str | None = None, + current_user: dict = Depends(get_current_user), +): + """ + 删除形象:清理 Kling 资源 + 删除 Redis 任务记录 + + 不操作数据库,形象数据由前端本地管理。 + """ + redis = get_redis_client() + state = await _get_avatar_state(redis, avatar_id) + + # 获取 Kling 资源 ID(优先用传入的,否则从 Redis 读) + actual_voice_id = voice_id + actual_element_id = None + if state: + params = state.get("params", {}) if isinstance(state.get("params"), dict) else {} + if params.get("user_id") == str(current_user.id): + actual_element_id = state.get("provider_element_id") + if not actual_voice_id: + actual_voice_id = state.get("voice_id") + + # 异步清理 Kling 资源(不阻塞前端) + provider = _get_kling_provider() + if actual_element_id: + try: + await provider.delete_element(str(actual_element_id)) + except Exception as e: + logger.warning(f"delete_element failed: {e}") + + if actual_voice_id: + try: + await provider.delete_custom_voice(actual_voice_id) + except Exception as e: + logger.warning(f"delete_custom_voice failed: {e}") + + # 删除 Redis 任务记录 + registry = JobRegistry(redis) + await registry.delete(avatar_id) + + return success_response(data={"success": True, "message": "形象已删除"}) + + +@router.get("/avatar/library", response_model=ApiResponse[list[AvatarItem]]) +async def get_avatar_library( + current_user: dict = Depends(get_current_user), +): + """ + 获取当前用户的克隆形象库 + + 形象数据只保存在前端本地,后端不持久化。 + 此接口始终返回空列表,由前端从 localStorage/文件系统读取真实数据。 + """ + return success_response(data=[]) + + +@router.patch("/avatar/{avatar_id}", response_model=ApiResponse[dict]) +async def update_avatar_name( + avatar_id: str, + data: UpdateAvatarNameRequest, + current_user: dict = Depends(get_current_user), +): + """ + 更新形象名称 + + 形象数据由前端本地管理,后端仅返回成功。 + """ + new_name = data.name.strip() + if not new_name: + raise HTTPException(status_code=400, detail="名称不能为空") + + return success_response(data={"success": True, "name": new_name}) + + +# ============================================================================= +# 管理和监控接口(用于排查问题和手动恢复) +# ============================================================================= + + +class AvatarHealthResponse(BaseModel): + """形象克隆服务健康状态""" + + total_processing: int = Field(..., description="处理中的任务总数") + pending: int = Field(..., description="待处理任务数") + voice_processing: int = Field(..., description="音色生成中任务数") + element_processing: int = Field(..., description="主体生成中任务数") + stuck_tasks: int = Field(..., description="卡住任务数(超过30分钟)") + recent_failures: int = Field(..., description="最近1小时失败数") + + +@router.get("/avatar/health", response_model=ApiResponse[AvatarHealthResponse]) +async def get_avatar_health( + current_user: dict = Depends(get_current_user), +): + """ + 获取形象克隆服务健康状态 + + 基于 Redis 运行中任务统计,不查询数据库。 + """ + redis = get_redis_client() + registry = JobRegistry(redis) + job_ids = await registry.get_running_job_ids() + + total_processing = 0 + pending = 0 + voice_processing = 0 + element_processing = 0 + stuck_tasks = 0 + recent_failures = 0 + + now = datetime.now(UTC) + stuck_threshold = now.timestamp() - 30 * 60 # 30 分钟前 + recent_threshold = now.timestamp() - 60 * 60 # 1 小时前 + + for job_id in job_ids: + state = await _get_avatar_state(redis, job_id) + if not state: + continue + + # 只统计当前用户的任务(非管理员) + params = state.get("params", {}) if isinstance(state.get("params"), dict) else {} + if params.get("user_id") != str(current_user.id): + continue + + job_type = state.get("type", "") + if job_type != "avatar_clone": + continue + + avatar_status = state.get("avatar_status", state.get("status", "")) + total_processing += 1 + + if avatar_status == AvatarCloneStatus.PENDING.value: + pending += 1 + elif avatar_status == AvatarCloneStatus.VOICE_PROCESSING.value: + voice_processing += 1 + elif avatar_status == AvatarCloneStatus.ELEMENT_PROCESSING.value: + element_processing += 1 + + # 检查是否卡住(updated_at 超过 30 分钟) + updated_at_raw = state.get("updated_at", "") + if updated_at_raw: + try: + updated_ts = datetime.fromisoformat(updated_at_raw).timestamp() + if updated_ts < stuck_threshold and avatar_status in ( + AvatarCloneStatus.PENDING.value, + AvatarCloneStatus.VOICE_PROCESSING.value, + AvatarCloneStatus.ELEMENT_PROCESSING.value, + ): + stuck_tasks += 1 + except ValueError: + pass + + # 检查最近失败 + if avatar_status in ( + AvatarCloneStatus.VOICE_FAILED.value, + AvatarCloneStatus.ELEMENT_FAILED.value, + AvatarCloneStatus.TIMEOUT.value, + ): + updated_at_raw = state.get("updated_at", "") + if updated_at_raw: + try: + updated_ts = datetime.fromisoformat(updated_at_raw).timestamp() + if updated_ts >= recent_threshold: + recent_failures += 1 + except ValueError: + pass + + return success_response( + data=AvatarHealthResponse( + total_processing=total_processing, + pending=pending, + voice_processing=voice_processing, + element_processing=element_processing, + stuck_tasks=stuck_tasks, + recent_failures=recent_failures, + ) + ) + + +@router.post("/avatar/admin/trigger-recovery", response_model=ApiResponse[dict]) +async def admin_trigger_recovery( + current_user: dict = Depends(get_current_user), +): + """ + 手动触发卡住任务恢复(管理员接口) + + Async Engine 会自动轮询,无需手动触发恢复。 + """ + # 权限检查:基于特定手机号判断管理员 + is_admin = current_user.mobile in ["13800138000", "admin"] + if not is_admin: + raise HTTPException(status_code=403, detail="需要管理员权限") + + return success_response( + data={ + "message": "Async Engine 会持续自动轮询,无需手动触发恢复", + "task_id": None, + } + ) diff --git a/python-api/app/api/v1/caption.py b/python-api/app/api/v1/caption.py new file mode 100644 index 0000000..8acbaf7 --- /dev/null +++ b/python-api/app/api/v1/caption.py @@ -0,0 +1,374 @@ +""" +火山引擎音视频字幕 API 路由 +============================ + +提供字幕生成、自动打轴等功能。 +""" + +import logging + +from fastapi import APIRouter, HTTPException + +from app.schemas.caption import ( + AutoAlignResult, + AutoAlignSubmitRequest, + CaptionResult, + CaptionSubmitRequest, + CaptionTaskResponse, + SrtSubtitleResponse, +) +from app.schemas.common import ApiResponse, success_response +from app.services.volcengine_caption_service import ( + VolcengineCaptionError, + VolcengineCaptionService, + get_caption_service, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/caption", tags=["Caption"]) + + +@router.post("/submit", response_model=ApiResponse[CaptionTaskResponse]) +async def submit_caption_task(request: CaptionSubmitRequest): + """ + 提交字幕生成任务 + + 提交音频/视频文件URL,生成带时间轴的字幕。 + """ + try: + service = await get_caption_service() + task_id = await service.submit_caption_task( + audio_url=request.audio_url, + language=request.language, + caption_type=request.caption_type, + use_punc=request.use_punc, + use_itn=request.use_itn, + words_per_line=request.words_per_line, + max_lines=request.max_lines, + ) + + return success_response( + data=CaptionTaskResponse( + task_id=task_id, + status="pending", + ), + message="字幕任务已提交", + ) + + except VolcengineCaptionError as e: + logger.error(f"提交字幕任务失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"提交字幕任务异常: {e}") + raise HTTPException(status_code=500, detail=f"提交失败: {str(e)}") + + +@router.get("/query/{task_id}", response_model=ApiResponse[CaptionResult]) +async def query_caption_task(task_id: str, blocking: bool = True): + """ + 查询字幕任务结果 + + Args: + task_id: 任务ID + blocking: 是否阻塞等待结果 (默认True) + """ + try: + service = await get_caption_service() + result = await service.query_caption_task(task_id, blocking=blocking) + + return success_response(data=result) + + except VolcengineCaptionError as e: + logger.error(f"查询字幕任务失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"查询字幕任务异常: {e}") + raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") + + +@router.post("/generate", response_model=ApiResponse[CaptionResult]) +async def generate_caption(request: CaptionSubmitRequest, max_wait_time: int = 120): + """ + 生成字幕(完整流程) + + 提交任务并轮询结果,直接返回最终字幕数据。 + 适用于不需要异步处理的场景。 + """ + try: + service = await get_caption_service() + result = await service.generate_caption( + audio_url=request.audio_url, + language=request.language, + caption_type=request.caption_type, + use_punc=request.use_punc, + use_itn=request.use_itn, + words_per_line=request.words_per_line, + max_lines=request.max_lines, + max_wait_time=max_wait_time, + ) + + return success_response(data=result) + + except VolcengineCaptionError as e: + logger.error(f"生成字幕失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"生成字幕异常: {e}") + raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") + + +@router.post("/generate-ass", response_model=ApiResponse[dict]) +async def generate_ass( + request: CaptionSubmitRequest, + video_width: int = 1080, + video_height: int = 1920, + max_wait_time: int = 120, +): + """ + 生成 ASS 格式字幕(完整流程,使用抖音美好体) + + Args: + video_width: 视频宽度(默认 1080) + video_height: 视频高度(默认 1920) + """ + try: + service = await get_caption_service() + result = await service.generate_caption( + audio_url=request.audio_url, + language=request.language, + caption_type=request.caption_type, + use_punc=request.use_punc, + use_itn=request.use_itn, + words_per_line=request.words_per_line, + max_lines=request.max_lines, + max_wait_time=max_wait_time, + ) + + ass_content = service.to_ass( + result.utterances, + video_width=video_width, + video_height=video_height, + ) + + return success_response( + data={ + "ass_content": ass_content, + "utterances": result.utterances, + "duration": result.duration, + "font": "DouyinSansBold", + } + ) + + except Exception as e: + logger.error(f"生成ASS字幕失败: {e}") + raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") + + +@router.post("/generate-srt", response_model=ApiResponse[SrtSubtitleResponse]) +async def generate_srt(request: CaptionSubmitRequest, max_wait_time: int = 120): + """ + 生成 SRT 格式字幕(完整流程) + + 直接返回 SRT 格式字幕文件内容。 + """ + try: + service = await get_caption_service() + result = await service.generate_caption( + audio_url=request.audio_url, + language=request.language, + caption_type=request.caption_type, + use_punc=request.use_punc, + use_itn=request.use_itn, + words_per_line=request.words_per_line, + max_lines=request.max_lines, + max_wait_time=max_wait_time, + ) + + srt_content = service.to_srt(result.utterances) + + return success_response( + data=SrtSubtitleResponse( + srt_content=srt_content, + utterances=result.utterances, + ) + ) + + except VolcengineCaptionError as e: + logger.error(f"生成SRT字幕失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"生成SRT字幕异常: {e}") + raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}") + + +@router.post("/ata/submit", response_model=ApiResponse[CaptionTaskResponse]) +async def submit_auto_align_task(request: AutoAlignSubmitRequest): + """ + 提交自动字幕打轴任务 + + 为已有字幕文本自动配上时间轴。 + """ + try: + service = await get_caption_service() + task_id = await service.submit_auto_align_task( + audio_url=request.audio_url, + audio_text=request.audio_text, + caption_type=request.caption_type, + sta_punc_mode=request.sta_punc_mode, + ) + + return success_response( + data=CaptionTaskResponse( + task_id=task_id, + status="pending", + ), + message="打轴任务已提交", + ) + + except VolcengineCaptionError as e: + logger.error(f"提交打轴任务失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"提交打轴任务异常: {e}") + raise HTTPException(status_code=500, detail=f"提交失败: {str(e)}") + + +@router.get("/ata/query/{task_id}", response_model=ApiResponse[AutoAlignResult]) +async def query_auto_align_task(task_id: str, blocking: bool = True): + """ + 查询打轴任务结果 + """ + try: + service = await get_caption_service() + result = await service.query_auto_align_task(task_id, blocking=blocking) + + return success_response(data=result) + + except VolcengineCaptionError as e: + logger.error(f"查询打轴任务失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"查询打轴任务异常: {e}") + raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") + + +@router.post("/ata/align") +async def auto_align_caption(request: AutoAlignSubmitRequest, max_wait_time: int = 120): + """ + 自动字幕打轴(完整流程) + + 提交打轴任务并轮询结果,直接返回最终数据。 + """ + try: + logger.info(f"[Caption API] Auto align request: audio_url={request.audio_url[:50]}...") + service = await get_caption_service() + result = await service.auto_align_caption( + audio_url=request.audio_url, + audio_text=request.audio_text, + caption_type=request.caption_type, + sta_punc_mode=request.sta_punc_mode, + max_wait_time=max_wait_time, + ) + logger.info( + f"[Caption API] Auto align result: utterances_count={len(result.utterances) if result.utterances else 0}" + ) + if result.utterances: + logger.info(f"[Caption API] First utterance: {result.utterances[0]}") + + # 手动序列化为字典,确保嵌套模型正确处理 + response_data = { + "code": 0, + "message": "Success", + "duration": result.duration, + "utterances": [ + { + "text": u.text, + "start_time": u.start_time, + "end_time": u.end_time, + } + for u in (result.utterances or []) + ], + } + logger.info(f"[Caption API] Response data: {response_data}") + return success_response(data=response_data) + + except VolcengineCaptionError as e: + logger.error(f"自动打轴失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"自动打轴异常: {e}") + raise HTTPException(status_code=500, detail=f"打轴失败: {str(e)}") + + +@router.post("/convert/ass", response_model=ApiResponse[dict]) +async def convert_to_ass( + result: CaptionResult, + video_width: int = 1080, + video_height: int = 1920, +): + """ + 将字幕结果转换为 ASS 格式(使用抖音美好体) + """ + try: + service = VolcengineCaptionService("", "") # 不需要认证 + ass_content = service.to_ass( + result.utterances, + video_width=video_width, + video_height=video_height, + ) + + return success_response( + data={ + "ass_content": ass_content, + "font": "DouyinSansBold", + "utterances_count": len(result.utterances), + } + ) + + except Exception as e: + logger.error(f"转换ASS失败: {e}") + raise HTTPException(status_code=500, detail=f"转换失败: {str(e)}") + + +@router.post("/convert/srt", response_model=ApiResponse[dict]) +async def convert_to_srt(result: CaptionResult): + """ + 将字幕结果转换为 SRT 格式 + + 用于将 /generate 返回的原始数据转换为 SRT 格式。 + """ + try: + service = VolcengineCaptionService("", "") # 不需要认证 + srt_content = service.to_srt(result.utterances) + + return success_response( + data={ + "srt_content": srt_content, + "utterances_count": len(result.utterances), + } + ) + + except Exception as e: + logger.error(f"转换SRT失败: {e}") + raise HTTPException(status_code=500, detail=f"转换失败: {str(e)}") + + +@router.post("/convert/vtt", response_model=ApiResponse[dict]) +async def convert_to_vtt(result: CaptionResult): + """ + 将字幕结果转换为 WebVTT 格式 + """ + try: + service = VolcengineCaptionService("", "") # 不需要认证 + vtt_content = service.to_vtt(result.utterances) + + return success_response( + data={ + "vtt_content": vtt_content, + "utterances_count": len(result.utterances), + } + ) + + except Exception as e: + logger.error(f"转换VTT失败: {e}") + raise HTTPException(status_code=500, detail=f"转换失败: {str(e)}") diff --git a/python-api/app/api/v1/klingai.py b/python-api/app/api/v1/klingai.py new file mode 100644 index 0000000..a0cfc8b --- /dev/null +++ b/python-api/app/api/v1/klingai.py @@ -0,0 +1,1046 @@ +""" +KlingAI (可灵 AI) API 路由 +========================== + +提供视频生成、图像生成、对口型等功能。 +""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.ai.providers.klingai_provider import KlingAIProvider +from app.config import get_settings +from app.core.config_loader import get_config_loader +from app.schemas.common import ApiResponse, success_response + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/klingai", tags=["KlingAI"]) + + +# ============ 请求/响应 Schema ============ + + +class OmniVideoRequest(BaseModel): + """Omni-Video 视频生成请求(kling-v3-omni / kling-video-o1)""" + + prompt: str = Field( + ..., + description="视频描述提示词(不超过2500字符),支持 <<>>/<<>>/<<>> 引用语法", + example="一只<<>>在花园里奔跑,阳光明媚", + ) + model: str | None = Field("kling-v3-omni", description="模型: kling-v3-omni, kling-video-o1") + duration: int = Field(5, ge=3, le=15, description="视频时长(秒): 3/5/10/15,Omni支持3-15秒") + aspect_ratio: str = Field("16:9", description="宽高比: 16:9, 9:16, 1:1") + mode: str = Field("pro", description="生成模式: pro(高质量) 或 std(标准)") + sound: str = Field("on", description="声音控制: on=音画同出, off=无声") + negative_prompt: str | None = Field(None, description="负面提示词") + # 多镜头参数 + multi_shot: bool = Field(False, description="是否启用多镜头模式") + shot_type: str | None = Field( + None, description="分镜方式: customize=自定义, intelligence=智能分镜" + ) + multi_prompt: list[dict | None] = Field( + None, + description="多镜头提示词列表,每个元素包含 index, prompt, duration,最多6个分镜", + ) + # 参考资源 + image_list: list[dict | None] = Field(None, description="参考图片列表,最多4张") + element_list: list[dict | None] = Field( + None, description="主体参考列表,格式: [{'elementId': 123}], 最多7个" + ) + video_list: list[dict | None] = Field(None, description="参考视频列表") + callback_url: str | None = Field(None, description="回调通知地址") + external_task_id: str | None = Field(None, description="自定义任务ID") + + +class VideoGenerateRequest(BaseModel): + """文生视频请求""" + + prompt: str = Field( + ..., + description="视频描述提示词(不超过2500字符)", + example="一只猫在花园里玩耍", + ) + model: str | None = Field("kling-v2.6", description="视频模型: kling-v2.6, kling-v2.5-turbo") + duration: int = Field(5, ge=5, le=10, description="视频时长(秒): 5 或 10") + aspect_ratio: str = Field("16:9", description="宽高比: 16:9, 9:16, 1:1, 4:3, 3:4") + mode: str = Field("pro", description="生成模式: pro(高质量) 或 std(标准)") + negative_prompt: str | None = Field(None, description="负面提示词") + callback_url: str | None = Field(None, description="回调通知地址") + + +class VideoGenerateResponse(BaseModel): + """视频生成响应""" + + task_id: str + task_status: str + created_at: int + updated_at: int + + +class Image2VideoRequest(BaseModel): + """图生视频请求""" + + image_url: str = Field(..., description="输入图片 URL") + prompt: str | None = Field(None, description="视频运动描述提示词") + model: str | None = Field("kling-v2.6", description="视频模型") + duration: int = Field(5, ge=5, le=10, description="视频时长(秒)") + aspect_ratio: str | None = Field(None, description="宽高比") + mode: str = Field("pro", description="生成模式") + callback_url: str | None = Field(None, description="回调通知地址") + + +class IdentifyFaceRequest(BaseModel): + """人脸识别请求""" + + video_id: str | None = Field(None, description="KlingAI 生成的视频 ID") + video_url: str | None = Field(None, description="上传的视频 URL(与 videoId 二选一") + + +class IdentifyFaceResponse(BaseModel): + """人脸识别响应""" + + session_id: str + face_data: list[dict[str, Any]] + + +class FaceChooseItem(BaseModel): + """新版对口型人脸配置""" + + face_id: str = Field(..., description="人脸ID,由 identify-face 接口返回") + audio_id: str | None = Field(None, description="通过TTS生成的音频ID(与 sound_file 二选一)") + sound_file: str | None = Field(None, description="音频文件URL或Base64(与 audio_id 二选一)") + sound_start_time: int = Field(0, description="音频裁剪起点时间(ms)") + sound_end_time: int = Field(..., description="音频裁剪终点时间(ms)") + sound_insert_time: int = Field(0, description="裁剪后音频插入时间(ms)") + + +class AdvancedLipSyncRequest(BaseModel): + """新版对口型请求(advanced-lip-sync)""" + + session_id: str = Field(..., description="人脸识别返回的会话ID") + face_choose: list[FaceChooseItem] = Field(..., description="人脸对口型配置列表") + callback_url: str | None = Field(None, description="回调通知地址") + + +class OmniImageRequest(BaseModel): + """Omni-Image 图像生成请求""" + + prompt: str = Field(..., description="图像描述提示词") + model: str | None = Field("kling-image-o1", description="模型: kling-image-o1, kling-v3-omni") + aspect_ratio: str | None = Field("9:16", description="宽高比: 16:9/9:16/1:1/4:3/3:4") + resolution: str | None = Field("1k", description="清晰度: 1k/2k/4k") + result_type: str | None = Field("single", description="结果类型: single/series") + n: int | None = Field(1, description="生成数量 1-9") + element_list: list[dict[str, Any]] | None = Field(None, description="主体参考列表") + image_list: list[dict[str, Any]] | None = Field(None, description="参考图列表") + callback_url: str | None = Field(None, description="回调通知地址") + + +class ImageGenerateRequest(BaseModel): + """图像生成请求""" + + prompt: str = Field(..., description="图像描述提示词") + model: str | None = Field("kolors-v1", description="图像模型: kolors-v1") + width: int = Field(1024, description="图像宽度") + height: int = Field(1024, description="图像高度") + negative_prompt: str | None = Field(None, description="负面提示词") + callback_url: str | None = Field(None, description="回调通知地址") + + +class TaskStatusResponse(BaseModel): + """任务状态响应""" + + task_id: str + task_status: str # submitted, processing, succeed, failed + created_at: int + updated_at: int + video_url: str | None = None + image_url: str | None = None + error_message: str | None = None + + +class VirtualTryonRequest(BaseModel): + """虚拟试穿请求""" + + person_image_url: str = Field(..., description="人物图片 URL") + cloth_image_url: str = Field(..., description="衣服图片 URL") + callback_url: str | None = Field(None, description="回调通知地址") + + +# ============ 自定义音色 Schema ============ + + +class CreateCustomVoiceRequest(BaseModel): + """创建自定义音色请求""" + + voice_name: str = Field(..., description="音色名称(最多20字符)", example="我的音色") + audio_url: str | None = Field(None, description="音频文件URL(mp3/wav/mp4/mov)") + video_url: str | None = Field(None, description="视频文件URL") + video_id: str | None = Field( + None, description="历史作品ID(v2.6/sound=on/数字人/对口型生成的视频)" + ) + callback_url: str | None = Field(None, description="回调通知地址") + external_task_id: str | None = Field(None, description="自定义任务ID") + + +class ElementImage(BaseModel): + """主体参考图片(对应 KlingAI 官方格式 imageUrl)""" + + image_url: str = Field(..., description="图片URL") + name: str | None = Field(None, description="图片名称") + + +class ElementVideo(BaseModel): + """主体参考视频(对应 KlingAI 官方格式 videoUrl)""" + + video_url: str = Field(..., description="视频URL") + name: str | None = Field(None, description="视频名称") + + +class CreateElementRequest(BaseModel): + """创建主体请求""" + + element_name: str = Field(..., description="主体名称(最多20字符)", example="我的小猫") + element_description: str = Field( + ..., description="主体描述(最多100字符)", example="一只橘色的小猫,毛茸茸的" + ) + reference_type: str = Field("image_refer", description="参考类型: image_refer 或 video_refer") + element_image_list: list[ElementImage] | None = Field( + None, description="图片参考列表(图片定制时必填,第一个作为正面图)" + ) + element_video_list: list[ElementVideo] | None = Field( + None, description="视频参考列表(视频定制时必填,第一个作为正面视频)" + ) + element_voice_id: str | None = Field(None, description="音色ID,绑定音色到主体") + callback_url: str | None = Field(None, description="回调通知地址") + + +class ElementResponse(BaseModel): + """主体响应""" + + element_id: int | None = None + element_name: str | None = None + element_description: str | None = None + element_type: str | None = None # image_refer / video_refer + status: str | None = None + task_id: str | None = None + task_status: str | None = None + created_at: int | None = None + updated_at: int | None = None + element_image_list: dict | None = None + element_video_list: dict | None = None + element_voice_info: dict | None = None + owned_by: str | None = None + + +class CreateCustomVoiceResponse(BaseModel): + """创建自定义音色响应""" + + task_id: str + task_status: str + created_at: int + updated_at: int + + +class VoiceInfo(BaseModel): + """音色信息""" + + voice_id: str + voice_name: str + trial_url: str | None = None + owned_by: str | None = None + status: str | None = None + + +# ============ 辅助函数 ============ + + +async def get_klingai_provider() -> KlingAIProvider: + """获取 KlingAI Provider 实例 + + API Key 从 Settings 读取(符合配置规范) + """ + settings = get_settings() + config_loader = get_config_loader() + platform = config_loader.get_platform("klingai") + + if not platform: + raise HTTPException(status_code=404, detail="KlingAI 平台未配置") + + # 从 Settings 读取 AK/SK(符合配置规范:.env → Settings → 服务层) + access_key = settings.KLINGAI_ACCESS_KEY + secret_key = settings.KLINGAI_SECRET_KEY + + if not access_key or not secret_key: + raise HTTPException( + status_code=400, + detail="KlingAI Access Key 或 Secret Key 未配置,请设置环境变量 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY", + ) + + # 从 YAML 读取 base_url(模型配置) + base_url = platform.base_url if platform else None + + return KlingAIProvider( + { + "access_key": access_key, + "secret_key": secret_key, + "base_url": base_url or "https://api-beijing.klingai.com", + } + ) + + +# ============ API 路由 ============ + + +@router.post("/videos/omni", response_model=ApiResponse[VideoGenerateResponse]) +async def create_omni_video(data: OmniVideoRequest): + """ + Omni-Video 多模态视频生成 + + 支持文本、图片、主体、视频等多种输入方式组合生成视频。 + 适用于 kling-v3-omni 和 kling-video-o1 模型。 + + **特性:** + - 支持 3-15 秒视频生成 + - 支持多镜头和智能分镜 (shotType=intelligence) + - 支持引用主体、图片、视频作为参考 + - 支持 <<>>/<<>>/<<>> 语法引用提示词中的资源 + """ + try: + provider = await get_klingai_provider() + + result = await provider.generate_video_omni( + prompt=data.prompt, + model=data.model, + mode=data.mode, + aspect_ratio=data.aspect_ratio, + duration=data.duration, + sound=data.sound, + negative_prompt=data.negative_prompt, + multi_shot=data.multi_shot, + shot_type=data.shot_type, + multi_prompt=data.multi_prompt, + image_list=data.image_list, + element_list=data.element_list, + video_list=data.video_list, + callback_url=data.callback_url, + external_task_id=data.external_task_id, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Omni-Video 生成失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/videos/omni/{task_id}", response_model=ApiResponse[TaskStatusResponse]) +async def get_omni_video_task(task_id: str): + """ + 查询 Omni-Video 任务状态 + + 查询指定 Omni-Video 任务的执行状态和结果。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.get_omni_video_task(task_id) + + return success_response(data=TaskStatusResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询 Omni-Video 任务失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/videos/omni", response_model=ApiResponse[dict]) +async def list_omni_video_tasks( + page: int = 1, + page_size: int = 30, +): + """ + 查询 Omni-Video 任务列表 + + 查询历史 Omni-Video 任务列表。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.list_omni_video_tasks( + page=page, + page_size=page_size, + ) + + return success_response(data=result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询 Omni-Video 任务列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/videos/image2video", response_model=ApiResponse[VideoGenerateResponse]) +async def create_image_to_video(data: Image2VideoRequest): + """ + 图生视频 + + 根据输入图片生成视频。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.generate_video_from_image( + image_url=data.image_url, + prompt=data.prompt, + model=data.model, + duration=data.duration, + aspect_ratio=data.aspect_ratio, + mode=data.mode, + callback_url=data.callback_url, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"图生视频失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/videos/extend", response_model=ApiResponse[VideoGenerateResponse]) +async def extend_video( + video_id: str, + prompt: str | None = None, + duration: int = 5, + callback_url: str | None = None, +): + """ + 视频延长 + + 延长现有视频的时长。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.extend_video( + video_id=video_id, + prompt=prompt, + duration=duration, + callback_url=callback_url, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"视频延长失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/videos/identify-face", response_model=ApiResponse[IdentifyFaceResponse]) +async def identify_face(data: IdentifyFaceRequest): + """ + 对口型前置:人脸识别 + + 分析视频中的人脸信息,返回 sessionId 和 faceId,用于后续的 advanced-lip-sync。 + """ + try: + if not data.videoId and not data.videoUrl: + raise HTTPException(status_code=400, detail="必须提供 videoId 或 videoUrl") + + provider = await get_klingai_provider() + result = await provider.identify_face(video_id=data.videoId, video_url=data.videoUrl) + + return success_response(data=IdentifyFaceResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"人脸识别失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/videos/advanced-lip-sync", response_model=ApiResponse[VideoGenerateResponse]) +async def create_advanced_lip_sync(data: AdvancedLipSyncRequest): + """ + 新版对口型视频生成 + + 基于 KlingAI advanced-lip-sync 接口,先调用 /videos/identify-face 获取 sessionId 和 faceId, + 再传入本接口生成对口型视频。 + + 支持 audio_id(TTS 生成)或 soundFile(外部音频 URL)驱动口型。 + """ + try: + provider = await get_klingai_provider() + + face_choose = [item.model_dump() for item in data.faceChoose] + + result = await provider.advanced_lip_sync( + session_id=data.sessionId, + face_choose=face_choose, + callback_url=data.callbackUrl, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"对口型生成失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/videos/advanced-lip-sync/{taskId}", response_model=ApiResponse[TaskStatusResponse]) +async def get_advanced_lip_sync_task(taskId: str): + """ + 查询新版对口型任务状态 + """ + try: + provider = await get_klingai_provider() + result = await provider.get_advanced_lip_sync_task(taskId) + + taskStatus = result.get("taskStatus", "unknown") + videos = result.get("task_result", {}).get("videos", []) + videoUrl = videos[0].get("url") if videos else None + + return success_response( + data=TaskStatusResponse( + taskId=result.get("taskId", taskId), + taskStatus=taskStatus, + createdAt=result.get("createdAt", 0), + updatedAt=result.get("updatedAt", 0), + videoUrl=videoUrl, + errorMessage=result.get("taskStatus_msg"), + ) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询对口型任务失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/images/omni", response_model=ApiResponse[VideoGenerateResponse]) +async def create_omni_image(data: OmniImageRequest): + """ + Omni-Image 图像生成 + + 支持文本、主体、参考图等多种输入方式组合生成图像。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.generate_omni_image( + prompt=data.prompt, + model=data.model, + aspect_ratio=data.aspect_ratio, + resolution=data.resolution, + result_type=data.result_type, + n=data.n, + element_list=data.element_list, + image_list=data.image_list, + callback_url=data.callback_url, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Omni-Image 生成失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/images/omni/{task_id}", response_model=ApiResponse[TaskStatusResponse]) +async def get_omni_image_task(task_id: str): + """ + 查询 Omni-Image 任务状态 + """ + try: + provider = await get_klingai_provider() + result = await provider.get_omni_image_task(task_id) + + task_status = result.get("task_status", "unknown") + images = result.get("task_result", {}).get("images", []) + image_url = images[0].get("url") if images else None + + return success_response( + data=TaskStatusResponse( + task_id=result.get("task_id", task_id), + task_status=task_status, + created_at=result.get("created_at", 0), + updated_at=result.get("updated_at", 0), + image_url=image_url, + error_message=result.get("task_status_msg"), + ) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询 Omni-Image 任务失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/images/generations", response_model=ApiResponse[VideoGenerateResponse]) +async def create_image(data: ImageGenerateRequest): + """ + 文生图 + + 根据文本描述生成图像。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.generate_image( + prompt=data.prompt, + model=data.model, + width=data.width, + height=data.height, + negative_prompt=data.negativePrompt, + callback_url=data.callbackUrl, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"图像生成失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/virtual-tryon", response_model=ApiResponse[VideoGenerateResponse]) +async def create_virtual_tryon(data: VirtualTryonRequest): + """ + 虚拟试穿 + + 将衣服虚拟试穿到人物身上。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.virtual_tryon( + person_image_url=data.person_imageUrl, + cloth_image_url=data.cloth_imageUrl, + callback_url=data.callbackUrl, + ) + + return success_response(data=VideoGenerateResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"虚拟试穿失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/tasks/{taskId}", response_model=ApiResponse[TaskStatusResponse]) +async def get_taskStatus( + taskId: str, + task_type: str = "video", +): + """ + 查询任务状态 + + 查询指定任务的执行状态和结果。 + + Args: + taskId: 任务 ID + task_type: 任务类型 (video, image2video, image, lip-sync, virtual-tryon) + """ + try: + provider = await get_klingai_provider() + + result = await provider.get_taskStatus( + task_id=taskId, + task_type=task_type, + ) + + return success_response(data=TaskStatusResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询任务状态失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/tasks", response_model=ApiResponse[dict]) +async def list_tasks( + task_type: str = "video", + page: int = 1, + page_size: int = 10, +): + """ + 查询任务列表 + + 查询历史任务列表。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.list_tasks( + task_type=task_type, + page=page, + page_size=page_size, + ) + + return success_response(data=result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询任务列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============ 主体管理 API ============ + + +@router.post("/elements", response_model=ApiResponse[ElementResponse]) +async def create_element(data: CreateElementRequest): + """ + 创建主体(自定义元素) + + 通过上传图片或视频创建可复用的主体,用于视频/图像生成时保持角色一致性。 + + 图片要求: + - 格式:jpg, jpeg, png + - 大小:≤10MB + - 数量:正面图 + 1-3张其他角度 + + 视频要求: + - 格式:mp4, mov + - 时长:3-8秒 + - 分辨率:1080P + - 大小:≤200MB + """ + try: + provider = await get_klingai_provider() + + imageList = None + if data.element_imageList: + imageList = { + "frontalImage": data.element_imageList[0].imageUrl, + "referImages": [ + {"imageUrl": img.imageUrl, "name": img.name} + for img in data.element_imageList[1:] + if img.imageUrl + ], + } + + videoList = None + if data.element_videoList: + videoList = { + "frontal_video": data.element_videoList[0].videoUrl, + "referVideos": [ + {"videoUrl": vid.videoUrl, "name": vid.name} + for vid in data.element_videoList[1:] + if vid.videoUrl + ], + } + + result = await provider.create_element( + element_name=data.elementName, + element_description=data.elementDescription, + reference_type=data.referenceType, + element_image_list=imageList, + element_video_list=videoList, + element_voice_id=data.element_voiceId, + callback_url=data.callbackUrl, + ) + + return success_response(data=ElementResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"创建主体失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/elements", response_model=ApiResponse[list[ElementResponse]]) +async def list_elements(): + """ + 查询主体列表 + + 获取所有已创建的主体(自定义元素)列表。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.list_elements() + + elements = [ElementResponse(**item) for item in result if isinstance(item, dict)] + + return success_response(data=elements) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询主体列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/elements/{elementId}", response_model=ApiResponse[ElementResponse]) +async def get_element(elementId: str): + """ + 查询单个主体详情 + + 获取指定主体的详细信息。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.get_element(elementId) + + return success_response(data=ElementResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询主体详情失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/elements/{elementId}", response_model=ApiResponse[dict]) +async def delete_element(elementId: str): + """ + 删除主体 + + 删除不再使用的主体(自定义元素)。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.delete_element(elementId) + + return success_response(data=result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除主体失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============ 智能补全主体图 API ============ + + +class AiMultiShotRequest(BaseModel): + """智能补全主体图请求""" + + frontal_image: str = Field( + ..., description="主体正面参考图 URL", example="https://example.com/front.jpg" + ) + callback_url: str | None = Field(None, description="回调通知地址") + + +class AiMultiShotResponse(BaseModel): + """智能补全主体图响应""" + + task_id: str + task_status: str + created_at: int + updated_at: int + + +@router.post("/elements/ai-multi-shot", response_model=ApiResponse[AiMultiShotResponse]) +async def ai_multiShot(data: AiMultiShotRequest): + """ + 智能补全主体不同角度图片 + + 通过主体正面图,自动推理出该主体其他角度图片。 + 每次可生成3组结果供选择,每次扣减0.5积分。 + + 使用流程: + 1. 调用此接口传入正面图 + 2. 轮询查询任务状态 + 3. 获取生成的多组角度图片 + 4. 选择合适的图片创建主体 + """ + try: + provider = await get_klingai_provider() + + result = await provider.ai_multiShot( + frontal_image=data.frontalImage, + callback_url=data.callbackUrl, + ) + + return success_response(data=AiMultiShotResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"智能补全主体图失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/elements/ai-multi-shot/{taskId}", response_model=ApiResponse[dict]) +async def get_ai_multiShot_task(taskId: str): + """ + 查询智能补全主体图任务状态 + + 获取指定任务的执行状态和生成的多角度图片结果。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.get_ai_multiShot_task(taskId) + + return success_response(data=result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询智能补全任务失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============ 自定义音色 API ============ + + +@router.post("/voices/custom", response_model=ApiResponse[CreateCustomVoiceResponse]) +async def create_custom_voice(data: CreateCustomVoiceRequest): + """ + 创建自定义音色 + + 通过上传音频文件或引用历史视频创建自定义音色,用于对口型视频。 + + 音频要求: + - 格式:mp3, wav, mp4, mov + - 时长:5-30 秒 + - 人声干净、无杂音、单一人声 + """ + try: + provider = await get_klingai_provider() + + result = await provider.create_custom_voice( + voice_name=data.voiceName, + audio_url=data.audioUrl, + video_url=data.videoUrl, + video_id=data.videoId, + callback_url=data.callbackUrl, + external_task_id=data.externalTaskId, + ) + + return success_response(data=CreateCustomVoiceResponse(**result)) + + except HTTPException: + raise + except Exception as e: + logger.error(f"创建自定义音色失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/voices/custom", response_model=ApiResponse[list[VoiceInfo]]) +async def list_custom_voices(): + """ + 查询自定义音色列表 + + 获取所有已创建的自定义音色。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.list_custom_voices() + + voices = [] + for item in result: + if isinstance(item, dict) and "task_result" in item: + task_result = item.get("task_result", {}) + voices_data = task_result.get("voices", []) + for voice in voices_data: + voices.append(VoiceInfo(**voice)) + + return success_response(data=voices) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询自定义音色列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/voices/custom/{voiceId}", response_model=ApiResponse[dict]) +async def get_custom_voice(voiceId: str): + """ + 查询单个自定义音色 + + 获取指定自定义音色的详细信息。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.get_custom_voice(voiceId) + + return success_response(data=result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询自定义音色失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/voices/presets", response_model=ApiResponse[list[VoiceInfo]]) +async def list_preset_voices(): + """ + 查询官方预设音色列表 + + 获取 KlingAI 提供的官方音色列表。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.list_preset_voices() + + voices = [] + for item in result: + if isinstance(item, dict) and "task_result" in item: + task_result = item.get("task_result", {}) + voices_data = task_result.get("voices", []) + for voice in voices_data: + voices.append(VoiceInfo(**voice)) + + return success_response(data=voices) + + except HTTPException: + raise + except Exception as e: + logger.error(f"查询官方音色列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/voices/custom/{voiceId}", response_model=ApiResponse[dict]) +async def delete_custom_voice(voiceId: str): + """ + 删除自定义音色 + + 删除不再使用的自定义音色。 + """ + try: + provider = await get_klingai_provider() + + result = await provider.delete_custom_voice(voiceId) + + return success_response(data=result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除自定义音色失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/python-api/app/api/v1/qiniu.py b/python-api/app/api/v1/qiniu.py new file mode 100644 index 0000000..013cad7 --- /dev/null +++ b/python-api/app/api/v1/qiniu.py @@ -0,0 +1,339 @@ +""" +七牛云对象存储 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/avatars/{userId}/{date}/{uuid}.{ext} + + 重复检测: + - 如果提供了 fileHash,会检查是否已有相同文件的任务在进行中 + - 返回的 isDuplicate 表示是否复用了已有资源 + - existingTaskId 表示已存在任务的ID(如果有) + """ + service = get_qiniu_service() + + # 使用当前登录用户的ID + effective_user_id = userId or str(current_user.id) + + suffix = Path(file.filename).suffix if file.filename else ".mp4" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + shutil.copyfileobj(file.file, tmp) + tmp_path = tmp.name + + try: + result = service.upload_avatar_video( + tmp_path, + user_id=effective_user_id, + file_hash=fileHash, + ) + + # 如果七牛云返回了现有文件,检查数据库中是否有进行中的任务 + if result.get("isDuplicate") and result.get("url"): + existing_task = await _check_existing_avatar_task(result["url"], effective_user_id) + if existing_task: + logger.info( + f"Found existing avatar task for uploaded file: {existing_task['task_id']}" + ) + result["existingTaskId"] = existing_task["task_id"] + result["message"] = "检测到相同视频的任务正在进行中,已复用现有任务" + + return success_response(data=FileUploadResponse(**result)) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.exception("Upload avatar failed") + raise HTTPException(status_code=500, detail=f"上传失败: {e}") + finally: + os.unlink(tmp_path) + + +@router.get("/files/{key:path}", response_model=ApiResponse[dict]) +async def get_file_info(key: str): + """ + 获取文件信息 + + Args: + key: 文件存储 Key(路径格式) + """ + try: + service = get_qiniu_service() + # 根据 key 推断 bucket + bucket = service.image_bucket if "/images/" in key else service.video_bucket + info = service.get_file_info(bucket, key) + + if info is None: + raise HTTPException(status_code=404, detail="文件不存在") + + return success_response(data=info) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取文件信息失败: {e}") + + +@router.delete("/files/{key:path}", response_model=ApiResponse[dict]) +async def delete_file(key: str): + """ + 删除文件 + + Args: + key: 文件存储 Key + """ + try: + service = get_qiniu_service() + # 根据 key 推断 bucket + bucket = service.image_bucket if "/images/" in key else service.video_bucket + success = service.delete_file(bucket, key) + + return success_response( + data={ + "success": success, + "key": key, + "message": "删除成功" if success else "删除失败或文件不存在", + } + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"删除失败: {e}") + + +@router.post("/refresh-cdn", response_model=ApiResponse[dict]) +async def refresh_cdn(keys: list[str]): + """ + 刷新 CDN 缓存 + + 文件更新后,调用此接口刷新 CDN 缓存,确保用户访问到最新内容。 + """ + try: + service = get_qiniu_service() + result = service.refresh_cdn(keys) + + return success_response(data=result) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"刷新 CDN 失败: {e}") diff --git a/python-api/app/api/v1/router.py b/python-api/app/api/v1/router.py new file mode 100644 index 0000000..f94cdd2 --- /dev/null +++ b/python-api/app/api/v1/router.py @@ -0,0 +1,51 @@ +""" +API v1 路由聚合 +============== +""" + +from fastapi import APIRouter + +from app.api.v1 import ( + ai_models, + auth, + avatar, + caption, + klingai, + qiniu, + script, + system, + tasks, + video, +) + +api_router = APIRouter() + +# 认证模块 +api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) + +# 脚本模块 +api_router.include_router(script.router, prefix="/script", tags=["Script"]) + +# AI 平台管理模块 +api_router.include_router(ai_models.router, prefix="/ai", tags=["AI Models"]) + +# KlingAI 模块(视频/图像生成) +api_router.include_router(klingai.router, tags=["KlingAI"]) + +# 七牛云对象存储模块 +api_router.include_router(qiniu.router, tags=["Qiniu Storage"]) + +# 视频生成模块 +api_router.include_router(video.router, tags=["Video"]) + +# 形象克隆模块 +api_router.include_router(avatar.router, tags=["Avatar"]) + +# 系统模块 +api_router.include_router(system.router, prefix="/system", tags=["System"]) + +# 字幕生成模块(火山引擎-豆包语音) +api_router.include_router(caption.router, tags=["Caption"]) + +# 统一任务管理模块 +api_router.include_router(tasks.router, tags=["Tasks"]) diff --git a/python-api/app/api/v1/script.py b/python-api/app/api/v1/script.py new file mode 100644 index 0000000..2aada9c --- /dev/null +++ b/python-api/app/api/v1/script.py @@ -0,0 +1,187 @@ +""" +脚本生成 API +============ + +提供脚本生成、润色、模型健康检查等功能。 +支持 SSE 流式响应。 +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from app.schemas.common import ApiResponse, success_response +from app.schemas.script import ( + GenerateScriptRequest, + ModelHealthResponse, + PolishRequest, + ScriptGenerationEvent, + ScriptShot, + TestModelRequest, + TestModelResponse, +) +from app.services.script_service import get_script_service + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.post("/generate", response_model=ApiResponse[list[ScriptShot]]) +async def generate_script(request: GenerateScriptRequest): + """ + 同步生成脚本 + + 直接返回生成的分镜列表,适合快速预览。 + """ + service = get_script_service() + + shots = await service.generate_script( + topic=request.topic, + duration=request.duration, + script_type=request.script_type, + model=request.model, + ) + + return success_response( + data=shots, + message=f"成功生成 {len(shots)} 个分镜", + ) + + +@router.post("/generate/stream") +async def generate_script_stream(request: Request, data: GenerateScriptRequest): + """ + 流式生成脚本(SSE) + + 返回 Server-Sent Events,包含进度更新和最终结果。 + 前端通过 EventSource 接收实时进度。 + + **SSE 事件类型:** + - `start`: 开始生成 + - `analyzing`: 分析主题 + - `planning`: 规划结构 + - `generating`: AI 生成中 + - `parsing`: 解析结果 + - `complete`: 完成,包含 result 字段 + - `error`: 错误 + + **示例事件流:** + ``` + data: {"type": "start", "progress": 0, "message": "开始生成脚本"} + + data: {"type": "analyzing", "progress": 15, "message": "分析目标受众..."} + + data: {"type": "complete", "progress": 100, "message": "成功生成 5 个分镜", "result": [...]} + ``` + """ + service = get_script_service() + + async def event_generator(): + """SSE 事件生成器,带客户端断开检测""" + try: + async for event in service.generate_script_stream( + topic=data.topic, + duration=data.duration, + script_type=data.script_type, + model=data.model, + ): + # 检查客户端是否已断开 + if await request.is_disconnected(): + logger.info("[SSE] 客户端已断开连接,停止生成") + break + + # SSE 格式:data: {...}\n\n + try: + yield f"data: {event.model_dump_json()}\n\n" + except Exception as e: + logger.error(f"[SSE] 序列化事件失败: {e}") + continue + + # 发送结束标记(如果客户端还连接着) + if not await request.is_disconnected(): + yield "data: [DONE]\n\n" + + except Exception as e: + logger.exception("[SSE] 事件生成器异常") + # 尝试发送错误信息给客户端 + try: + error_event = ScriptGenerationEvent( + type="error", + progress=0, + message=f"服务器错误: {str(e)}", + ) + yield f"data: {error_event.model_dump_json()}\n\n" + yield "data: [DONE]\n\n" + except: + pass + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # 禁用 Nginx 缓冲 + }, + ) + + +@router.post("/polish", response_model=ApiResponse[str]) +async def polish_content(request: PolishRequest): + """ + AI 润色文案/画面描述 + + - `polishType=scene`: 润色画面描述(根据 shot_type 自动区分分镜/空镜) + - `polishType=voiceover`: 润色配音文案 + + 参数: + - `shot_type`: "segment"(分镜)或 "empty_shot"(空镜),画面润色时必填 + """ + service = get_script_service() + + polished = await service.polish_content( + content=request.content, + polish_type=request.polish_type, + shot_type=request.shot_type or "segment", + ) + + type_name = "画面" if request.polish_type == "scene" else "文案" + return success_response( + data=polished, + message=f"{type_name}润色完成", + ) + + +@router.get("/model-health", response_model=ApiResponse[ModelHealthResponse]) +async def check_model_health(): + """ + 检查 AI 模型健康状态 + + 返回所有配置的模型及其可用性状态。 + """ + service = get_script_service() + health_data = await service.check_model_health() + + return success_response( + data=ModelHealthResponse(**health_data), + message="模型健康检查完成", + ) + + +@router.post("/test-model", response_model=ApiResponse[TestModelResponse]) +async def test_model(request: TestModelRequest): + """ + 测试指定模型连接 + + 发送一个简单的测试请求,验证模型是否可用。 + """ + service = get_script_service() + result = await service.test_model(request.model_id) + + return success_response( + data=TestModelResponse(**result), + message="模型测试完成" if result["success"] else f"模型测试失败: {result.get('error')}", + ) diff --git a/python-api/app/api/v1/system.py b/python-api/app/api/v1/system.py new file mode 100644 index 0000000..254b140 --- /dev/null +++ b/python-api/app/api/v1/system.py @@ -0,0 +1,43 @@ +""" +系统模块 API +============ +""" + +from fastapi import APIRouter + +from app.schemas.common import ApiResponse, success_response + +router = APIRouter() + + +@router.get("/health", response_model=ApiResponse[dict]) +async def system_health(): + """系统健康检查(详细版)""" + return success_response( + data={ + "status": "healthy", + "services": { + "api": "up", + "database": "unknown", # TODO: 检查数据库连接 + "redis": "unknown", # TODO: 检查 Redis 连接 + }, + }, + message="系统运行正常", + ) + + +@router.get("/version", response_model=ApiResponse[dict]) +async def system_version(): + """获取系统版本信息""" + from app.config import get_settings + + settings = get_settings() + + return success_response( + data={ + "name": settings.APP_NAME, + "version": settings.APP_VERSION, + "environment": settings.ENV, + }, + message="获取版本成功", + ) diff --git a/python-api/app/api/v1/tasks.py b/python-api/app/api/v1/tasks.py new file mode 100644 index 0000000..31937d4 --- /dev/null +++ b/python-api/app/api/v1/tasks.py @@ -0,0 +1,499 @@ +""" +统一任务管理 API +=============== + +提供任务创建和状态查询接口,支持: +- video: 视频生成 +- image: 图片生成 +- script: 脚本生成 +- subtitle: 字幕对齐 +- copy: 文案提取 +- avatar_clone: 形象克隆 +""" + +import json +import logging +import uuid +from datetime import UTC, datetime +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, field_validator + +from app.api.deps import get_current_user +from app.core.redis_client import get_redis_client +from app.models.user import User +from app.scheduler.registry import JobRegistry +from app.schemas.enums import AvatarCloneStatus +from app.schemas.segment import Segment + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tasks", tags=["Tasks"]) + + +# ========== 请求/响应模型 ========== + + +class VideoParams(BaseModel): + """视频生成参数""" + + segments: list[Segment] = Field(..., description="分镜列表") + human_id: int | None = Field(None, description="数字人主体ID") + + @field_validator("segments") + @classmethod + def validate_segments(cls, v: list[Segment]) -> list[Segment]: + if not v: + raise ValueError("segments 不能为空列表") + return v + + +class ImageParams(BaseModel): + """图片生成参数""" + + prompt: str = Field(..., min_length=1, description="图片描述") + image_type: str = Field(default="cover", description="图片类型: empty_shot/cover") + reference_image: str | None = Field(None, description="参考图片URL(图生图)") + human_id: int | None = Field(None, description="数字人主体ID(omni-image使用)") + + @field_validator("prompt") + @classmethod + def validate_prompt(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("prompt 不能为空") + return v.strip() + + +class ScriptParams(BaseModel): + """脚本生成参数""" + + topic: str = Field(..., min_length=1, description="创作主题") + style: str = Field(default="default", description="脚本风格") + duration: int = Field(default=60, ge=10, le=300, description="视频时长(秒)") + + @field_validator("topic") + @classmethod + def validate_topic(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("topic 不能为空") + return v.strip() + + +class SubtitleParams(BaseModel): + """字幕生成参数""" + + video_path: str = Field(..., min_length=1, description="视频文件路径") + language: str = Field(default="zh", description="语言代码") + mode: str = Field(default="caption", description="模式: caption/auto_align") + audio_text: str | None = Field(default=None, description="打轴文本(auto_align 模式必填)") + + @field_validator("video_path") + @classmethod + def validate_video_path(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("video_path 不能为空") + return v.strip() + + +class CopyParams(BaseModel): + """文案提取参数""" + + video_url: str = Field(..., min_length=1, description="视频链接URL") + + @field_validator("video_url") + @classmethod + def validate_video_url(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("video_url 不能为空") + if not v.startswith(("http://", "https://")): + raise ValueError("video_url 必须是有效的URL") + return v.strip() + + +class TaskCreateRequest(BaseModel): + """创建任务请求""" + + project_id: str | None = Field(None, description="项目ID(可选)") + params: dict = Field(default_factory=dict, description="任务参数") + + +class TaskCreateResponse(BaseModel): + """创建任务响应""" + + task_id: str = Field(..., description="任务ID") + status: str = Field("pending", description="任务状态") + message: str = Field("任务已创建", description="状态消息") + + +class TaskStatusResponse(BaseModel): + """任务状态响应""" + + task_id: str = Field(..., description="任务ID") + type: str | None = Field(None, description="任务类型") + status: str = Field(..., description="任务状态: pending/running/waiting/completed/failed") + progress: int = Field(0, description="进度百分比 (0-100)") + message: str = Field("", description="状态描述") + completed: int = Field(0, description="已完成子任务数") + total: int = Field(0, description="总子任务数") + result: dict | None = Field(None, description="任务结果(完成时)") + error: str | None = Field(None, description="错误信息(失败时)") + created_at: str = Field("", description="任务创建时间(ISO格式)") + + +# ========== 辅助函数 ========== + + +def _generate_task_id() -> str: + """生成任务ID""" + return f"task_{uuid.uuid4().hex[:16]}" + + +# ========== API 路由 ========== + + +@router.post("/{task_type}", response_model=TaskCreateResponse) +async def create_task( + task_type: Literal["video", "image", "script", "subtitle", "copy", "avatar_clone"], + request: TaskCreateRequest, + current_user: User = Depends(get_current_user), +) -> TaskCreateResponse: + """ + 创建新任务 + + 根据任务类型写入 Redis,由 Async Engine Scheduler 统一调度。 + """ + task_id = _generate_task_id() + user_id = str(current_user.id) + project_id = request.project_id or request.params.get("project_id", "") + + redis = get_redis_client() + registry = JobRegistry(redis) + + try: + await registry.create(task_id, task_type, user_id) + except Exception as e: + logger.error(f"[API] Failed to create registry entry: {e}") + raise HTTPException(status_code=500, detail="创建任务失败:Redis连接错误") + + try: + if task_type == "video": + # 字段适配:前端 shots/element_id → 后端 segments/human_id + import re + + video_params = dict(request.params) + if "shots" in video_params: + shots = video_params.pop("shots") + for s in shots: + # 清洗 id:前端可能发送数字,Segment 模型要求 str + if "id" in s and not isinstance(s["id"], str): + s["id"] = str(s["id"]) + # 清洗 duration:前端可能发送 "5s",Segment 模型要求 int + duration = s.get("duration") + if isinstance(duration, str): + m = re.search(r"\d+", duration) + s["duration"] = int(m.group()) if m else None + video_params["segments"] = shots + if "element_id" in video_params: + video_params["human_id"] = video_params.pop("element_id") + validated = VideoParams(**video_params) + segments = validated.segments + human_id = validated.human_id + + normalized_segments = [] + for s in segments: + normalized_segments.append( + { + "id": str(s.id), + "type": s.type, + "scene": s.scene, + "voiceover": s.voiceover, + "duration": s.duration, + "human_id": (human_id if s.type == "segment" else None), + "voice_id": s.voice_id, + "provider_task_id": None, + "status": "pending", + "video_url": None, + "local_path": None, + "qiniu_url": None, + "error_message": None, + } + ) + + await registry.update( + task_id, + status="running", + message=f"开始生成视频,共 {len(normalized_segments)} 个镜头...", + completed=0, + total=len(normalized_segments), + params={ + "project_id": project_id, + "user_id": user_id, + "human_id": human_id, + "shots": json.dumps(normalized_segments, ensure_ascii=False), + }, + ) + await registry.add_running(task_id) + + elif task_type == "image": + image_validated = ImageParams(**request.params) + await registry.update( + task_id, + status="running", + message="准备生成图片...", + completed=0, + total=1, + params={ + "project_id": project_id, + "user_id": user_id, + "prompt": image_validated.prompt, + "image_type": image_validated.image_type, + "reference_image": image_validated.reference_image, + "human_id": image_validated.human_id, + }, + ) + await registry.add_running(task_id) + + elif task_type == "script": + script_validated = ScriptParams(**request.params) + await registry.update( + task_id, + status="running", + progress=0, + message="等待执行...", + params={ + "topic": script_validated.topic, + "style": script_validated.style, + "duration": script_validated.duration, + }, + ) + await registry.add_running(task_id) + + elif task_type == "subtitle": + subtitle_validated = SubtitleParams(**request.params) + await registry.update( + task_id, + status="running", + message="准备字幕生成...", + completed=0, + total=1, + params={ + "project_id": project_id, + "video_path": subtitle_validated.video_path, + "language": subtitle_validated.language, + "mode": subtitle_validated.mode, + "audio_text": subtitle_validated.audio_text, + }, + ) + await registry.add_running(task_id) + + elif task_type == "copy": + copy_validated = CopyParams(**request.params) + await registry.update( + task_id, + status="running", + message="准备提取文案...", + completed=0, + total=1, + params={"video_url": copy_validated.video_url}, + ) + await registry.add_running(task_id) + + elif task_type == "avatar_clone": + name = request.params.get("name", "").strip() + video_url = request.params.get("video_url", "").strip() + if not name: + raise ValueError("name 不能为空") + if not video_url: + raise ValueError("video_url 不能为空") + if not video_url.startswith(("http://", "https://")): + raise ValueError("video_url 必须是有效的URL") + + avatar_id = f"avt_{uuid.uuid4().hex[:16]}" + now = datetime.now(UTC).isoformat() + + # avatar_clone 使用自己的 task_id(avt_xxx),不走通用的 task_xxx + await registry.create(avatar_id, "avatar_clone", user_id) + await registry.update( + avatar_id, + status="running", + progress=5, + message="开始形象克隆...", + completed=0, + total=1, + params={ + "avatar_id": avatar_id, + "name": name, + "video_url": video_url, + "user_id": user_id, + }, + avatar_status=AvatarCloneStatus.PENDING.value, + avatar_name=name, + avatar_video_url=video_url, + voice_id="", + provider_element_id="", + provider_voice_job_id="", + provider_element_job_id="", + trial_url="", + fail_reason="", + created_at=now, + updated_at=now, + ) + await registry.add_running(avatar_id) + # 返回的任务 ID 用 avatar_id,保持前端兼容 + task_id = avatar_id + + else: + raise HTTPException(status_code=400, detail=f"不支持的任务类型: {task_type}") + + logger.info(f"[API] Task created: {task_id}, type={task_type}, user={user_id}") + return TaskCreateResponse( + task_id=task_id, + status="pending", + message=f"{task_type} 任务已创建", + ) + + except ValueError as e: + logger.warning(f"[API] Invalid params for {task_type}: {e}") + try: + await registry.update(task_id, status="failed", message=f"参数错误: {e}", error=str(e)) + except Exception as registry_err: + logger.warning(f"[API] Failed to update registry for {task_id}: {registry_err}") + raise HTTPException(status_code=422, detail=f"参数错误: {e}") + + except HTTPException: + raise + + except Exception as e: + logger.exception(f"[API] Failed to create task: {e}") + try: + await registry.update(task_id, status="failed", message=str(e), error=str(e)) + except Exception as registry_err: + logger.warning(f"[API] Failed to update registry for {task_id}: {registry_err}") + raise HTTPException(status_code=500, detail=f"创建任务失败: {str(e)}") + + +def _map_avatar_status(status: str) -> str: + """将 AvatarCloneStatus 映射为统一任务状态""" + mapping = { + "succeed": "completed", + "voice_failed": "failed", + "element_failed": "failed", + "timeout": "failed", + "pending": "running", + "voice_processing": "running", + "element_pending": "running", + "element_processing": "running", + } + return mapping.get(status, "running") + + +@router.get("", response_model=list[TaskStatusResponse]) +async def list_tasks( + project_id: str | None = None, + current_user: User = Depends(get_current_user), +) -> list[TaskStatusResponse]: + """ + 查询当前用户所有进行中的任务 + + 从 Redis running 集合读取真实状态,支持按 project_id 过滤。 + """ + redis = get_redis_client() + registry = JobRegistry(redis) + + try: + jobs = await registry.list_running_by_user(str(current_user.id)) + except Exception as e: + logger.error(f"[API] Redis error when listing tasks: {e}") + raise HTTPException(status_code=503, detail="服务暂时不可用,请稍后重试") + + results: list[TaskStatusResponse] = [] + for job in jobs: + # 按 project_id 过滤 + if project_id and job.project_id != project_id: + continue + results.append( + TaskStatusResponse( + task_id=job.job_id, + type=job.job_type, + status=job.status, + progress=job.progress, + message=job.message, + completed=job.completed, + total=job.total, + result=None, # 列表查询不返回 result,避免数据过大 + error=job.error, + created_at=job.created_at, + ) + ) + return results + + +@router.get("/{task_id}", response_model=TaskStatusResponse) +async def get_task_status( + task_id: str, + current_user: User = Depends(get_current_user), +) -> TaskStatusResponse: + """ + 查询任务状态 + + 前端通过轮询此接口获取任务进度。 + 任务状态仅从 Redis 查询,记录过期后返回 404。 + """ + redis = get_redis_client() + registry = JobRegistry(redis) + + try: + job = await registry.get(task_id) + except Exception as e: + logger.error(f"[API] Redis error when getting task {task_id}: {e}") + raise HTTPException(status_code=503, detail="服务暂时不可用,请稍后重试") + + if not job: + raise HTTPException(status_code=404, detail="任务不存在或已过期") + + # 权限检查 + if job.user_id != str(current_user.id): + raise HTTPException(status_code=403, detail="无权访问此任务") + + return TaskStatusResponse( + task_id=task_id, + type=job.job_type, + status=job.status, + progress=job.progress, + message=job.message, + completed=job.completed, + total=job.total, + result=job.result, + error=job.error, + created_at=job.created_at, + ) + + +@router.get("/{task_id}/result") +async def get_task_result( + task_id: str, + current_user: User = Depends(get_current_user), +) -> dict: + """ + 获取任务结果(简化接口,直接返回 result 字段) + """ + redis = get_redis_client() + registry = JobRegistry(redis) + + try: + job = await registry.get(task_id) + except Exception as e: + logger.error(f"[API] Redis error when getting result {task_id}: {e}") + raise HTTPException(status_code=503, detail="服务暂时不可用,请稍后重试") + + if not job: + raise HTTPException(status_code=404, detail="任务不存在或已过期") + + if job.user_id != str(current_user.id): + raise HTTPException(status_code=403, detail="无权访问此任务") + + if job.status != "completed": + raise HTTPException(status_code=400, detail=f"任务未完成,当前状态: {job.status}") + + return job.result or {} diff --git a/python-api/app/api/v1/video.py b/python-api/app/api/v1/video.py new file mode 100644 index 0000000..c6c60a4 --- /dev/null +++ b/python-api/app/api/v1/video.py @@ -0,0 +1,511 @@ +""" +视频生成 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/projects/*/videos/{video_id}.mp4 - 项目生成的视频 + 文件名格式: scene_{shot_id}.mp4 + """ + try: + # 1. 首先查找传统存储位置 + video_path = VIDEO_STORAGE_DIR / f"{video_id}.mp4" + found = False + + if not video_path.exists(): + # 2. 尝试从上传目录查找 + for ext in [".mp4", ".mov", ".avi"]: + candidate = UPLOAD_DIR / f"{video_id}{ext}" + if candidate.exists(): + video_path = candidate + found = True + break + else: + found = True + + # 3. 如果还没找到,尝试在项目视频目录中查找 + # video_id 可能是 scene_{id} 格式 + if not found: + from app.services.kling_video_service import KlingVideoService + + # 遍历项目目录查找(递归查找) + base_dir = KlingVideoService.BASE_STORAGE_DIR + if base_dir.exists(): + for project_dir in base_dir.iterdir(): + if project_dir.is_dir(): + candidate = project_dir / "videos" / f"{video_id}.mp4" + if candidate.exists(): + video_path = candidate + found = True + break + + if not found or not video_path.exists(): + raise HTTPException(status_code=404, detail="视频文件不存在") + + return FileResponse(path=video_path, media_type="video/mp4", filename=f"{video_id}.mp4") + + except HTTPException: + raise + except Exception as e: + logger.error(f"下载视频失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{video_id}/thumbnail") +async def get_video_thumbnail(video_id: str): + """ + 获取视频缩略图 + """ + try: + # 简化实现:返回占位图 + # 实际应该使用 FFmpeg 提取视频第一帧 + raise HTTPException(status_code=404, detail="缩略图功能暂未实现") + + except Exception as e: + logger.error(f"获取缩略图失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/python-api/app/config.py b/python-api/app/config.py new file mode 100644 index 0000000..f3c9a17 --- /dev/null +++ b/python-api/app/config.py @@ -0,0 +1,208 @@ +""" +配置管理 - Pydantic Settings +========================== + +所有配置项通过环境变量或 .env 文件注入。 +""" + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """应用配置""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + arbitrary_types_allowed=True, + ) + + # 应用基础配置 + APP_NAME: str = Field(default="美家卡智影 API", description="应用名称") + APP_VERSION: str = Field(default="0.1.0", description="应用版本") + DEBUG: bool = Field(default=True, description="调试模式") + ENV: Literal["development", "staging", "production"] = Field( + default="development", description="运行环境" + ) + + # 服务器配置 + HOST: str = Field(default="0.0.0.0", description="监听地址") + PORT: int = Field(default=8000, description="监听端口") + WORKERS: int = Field(default=1, description="工作进程数(生产环境建议 > 1)") + + # 数据库配置(统一使用 PostgreSQL) + DATABASE_URL: str = Field( + default="postgresql+asyncpg://postgres:postgres@localhost:5432/meijiaka", + description="数据库连接字符串(PostgreSQL)", + ) + DATABASE_POOL_SIZE: int = Field(default=10, description="数据库连接池大小") + DATABASE_MAX_OVERFLOW: int = Field(default=20, description="连接池溢出上限") + + # Redis 配置 + REDIS_HOST: str = Field( + default="localhost", + description="Redis 主机地址", + ) + REDIS_PORT: int = Field( + default=6379, + description="Redis 端口", + ) + REDIS_DB: int = Field( + default=0, + description="Redis 数据库编号", + ) + REDIS_PASSWORD: str | None = Field( + default=None, + description="Redis 密码(无密码请留空)", + ) + + # 安全配置 + SECRET_KEY: str = Field( + default="your-secret-key-here-change-in-production", + description="JWT 签名密钥(生产环境必须修改)", + ) + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field( + default=60 * 24 * 7, # 7 天 + description="访问令牌过期时间(分钟)", + ) + ALGORITHM: str = Field(default="HS256", description="JWT 算法") + + # CORS 配置 + CORS_ORIGINS: str = Field( + default="http://localhost:1420,http://127.0.0.1:1420,http://localhost:8080,http://127.0.0.1:8080", + description="允许的跨域来源(逗号分隔)", + ) + + # AI 模型配置 + # 字节跳动 - 火山方舟 + # 文档:https://www.volcengine.com/docs/82379/1399009 + VOLCENGINE_API_KEY: str | None = Field(default=None, description="火山方舟 API Key") + VOLCENGINE_BASE_URL: str = Field( + default="https://ark.cn-beijing.volces.com/api/v3", + description="火山方舟 Base URL", + ) + VOLCENGINE_MODEL: str = Field( + default="doubao-seed-2-0-lite-260215", + description="火山方舟默认模型(Model ID)", + ) + + # 火山引擎音视频字幕服务 + VOLCENGINE_CAPTION_APPID: str | None = Field(default=None, description="火山字幕 AppID") + VOLCENGINE_CAPTION_TOKEN: str | None = Field(default=None, description="火山字幕 Token") + + # OpenAI + OPENAI_API_KEY: str | None = Field(default=None, description="OpenAI API Key") + OPENAI_BASE_URL: str = Field(default="https://api.openai.com/v1", description="OpenAI Base URL") + OPENAI_DEFAULT_MODEL: str = Field(default="gpt-3.5-turbo", description="默认 OpenAI 模型") + + # 文心一言 (百度) + WENXIN_API_KEY: str | None = Field(default=None, description="文心一言 API Key") + WENXIN_SECRET_KEY: str | None = Field(default=None, description="文心一言 Secret Key") + + # 通义千问 (阿里云) + QIANWEN_API_KEY: str | None = Field(default=None, description="通义千问 API Key") + + # 数字人服务配置 + DIGITAL_HUMAN_PROVIDER: Literal["heygen", "did", "mock"] = Field( + default="mock", + description="数字人服务提供商", + ) + HEYGEN_API_KEY: str | None = Field(default=None, description="HeyGen API Key") + DID_API_KEY: str | None = Field(default=None, description="D-ID API Key") + + # KlingAI 配置 + KLINGAI_ACCESS_KEY: str | None = Field(default=None, description="KlingAI Access Key") + KLINGAI_SECRET_KEY: str | None = Field(default=None, description="KlingAI Secret Key") + + # 七牛云存储配置 + QINIU_ACCESS_KEY: str | None = Field(default=None, description="七牛云 Access Key") + QINIU_SECRET_KEY: str | None = Field(default=None, description="七牛云 Secret Key") + QINIU_VIDEO_BUCKET: str = Field(default="media-liche", description="视频存储 Bucket") + QINIU_VIDEO_DOMAIN: str = Field(default="media.liche.cn", description="视频存储域名") + QINIU_IMAGE_BUCKET: str = Field(default="img-liche", description="图片存储 Bucket") + QINIU_IMAGE_DOMAIN: str = Field(default="img.liche.cn", description="图片存储域名") + + # AnyToCopy 文案提取服务 + ANYTOCOPY_API_KEY: str | None = Field(default=None, description="AnyToCopy API Key") + ANYTOCOPY_API_SECRET: str | None = Field(default=None, description="AnyToCopy API Secret") + ANYTOCOPY_BASE_URL: str = Field( + default="https://api.anytocopy.com/vip/open-api/v1", + description="AnyToCopy Base URL", + ) + + # 视频生成配置 + DEFAULT_EMPTY_SHOT_VOICE_ID: str = Field( + default="829826792415842333", + description="空镜视频默认音色ID(Kling官方音色,默认:播报男声)", + ) + + # Async Engine 槽位配置 + KLING_VIDEO_MAX_CONCURRENT: int = Field(default=18, description="Kling视频生成最大并发数") + KLING_IMAGE_MAX_CONCURRENT: int = Field(default=9, description="Kling图片生成最大并发数") + KLING_AVATAR_MAX_CONCURRENT: int = Field(default=2, description="Kling形象克隆最大并发数") + ANYTOCOPY_MAX_CONCURRENT: int = Field(default=5, description="AnyToCopy文案提取最大并发数") + VOLC_SUBTITLE_MAX_CONCURRENT: int = Field(default=5, description="火山字幕生成最大并发数") + + # 任务超时配置(秒) + KLING_VIDEO_TIMEOUT_PER_SHOT: int = Field( + default=600, description="Kling视频单镜头超时时间(秒)" + ) + KLING_IMAGE_TIMEOUT: int = Field(default=120, description="Kling图片生成超时时间(秒)") + VOLC_SUBTITLE_TIMEOUT: int = Field(default=600, description="火山字幕生成超时时间(秒)") + + # AnyToCopy 轮询配置 + ANYTOCOPY_POLL_INTERVAL: float = Field(default=3.0, description="AnyToCopy轮询间隔(秒)") + ANYTOCOPY_MAX_POLL: int = Field(default=60, description="AnyToCopy最大轮询次数") + + # 日志配置 + LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field( + default="DEBUG", + description="日志级别", + ) + + @property + def cors_origins_list(self) -> list[str]: + """将 CORS_ORIGINS 字符串解析为列表""" + return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + + @property + def use_redis(self) -> bool: + """是否使用 Redis""" + return bool(self.REDIS_HOST) + + +@lru_cache +def get_settings() -> Settings: + """获取配置单例(带缓存)""" + settings = Settings() + + # 生产环境安全检查 + if settings.ENV == "production": + default_keys = [ + "your-secret-key-here-change-in-production", + "change-me-in-production", + "secret-key", + "", + ] + if not settings.SECRET_KEY or settings.SECRET_KEY in default_keys: + raise ValueError( + "生产环境必须设置强随机 SECRET_KEY!" + "请在 .env 文件中设置一个随机字符串(至少 32 位)。" + ) + + # 检查 CORS 配置 + if settings.CORS_ORIGINS and "localhost" in settings.CORS_ORIGINS.lower(): + import warnings + + warnings.warn( + "生产环境 CORS 配置中包含 localhost,建议限制为实际域名", + RuntimeWarning, + stacklevel=2, + ) + + return settings diff --git a/python-api/app/core/__init__.py b/python-api/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/core/config_loader.py b/python-api/app/core/config_loader.py new file mode 100644 index 0000000..77fe2cc --- /dev/null +++ b/python-api/app/core/config_loader.py @@ -0,0 +1,231 @@ +""" +AI 模型配置加载器 +================ + +从 YAML 文件加载模型配置,支持热重载。 +API Key 从 Settings 读取(符合配置规范)。 +""" + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# 尝试导入 YAML 库 +try: + import yaml + + YAML_AVAILABLE = True +except ImportError: + YAML_AVAILABLE = False + logger.warning("PyYAML 未安装,使用 JSON 备选方案。安装: pip install pyyaml") + + +@dataclass +class PlatformConfig: + """平台配置""" + + id: str + name: str + provider: str + priority: int = 100 + base_url: str = "" # 从 YAML 读取,可选 + + +@dataclass +class ModelConfig: + """模型配置""" + + id: str + platform_id: str + model_name: str + display_name: str + capabilities: list[str] = field(default_factory=list) + default_params: dict[str, Any] = field(default_factory=dict) + is_enabled: bool = True + cost_per_1k_input: float = 0.0 + cost_per_1k_output: float = 0.0 + max_tokens_limit: int = 4096 + + +class AIModelConfigLoader: + """AI 模型配置加载器 + + 从 YAML 加载模型配置(支持热重载)。 + API Key 从 Settings 读取(通过 get_settings()),符合配置规范。 + """ + + DEFAULT_CONFIG_PATH = ( + Path(__file__).parent.parent.parent / "config" / "ai_models.yaml" + ) + + def __init__(self, config_path: str | None = None): + self.config_path = ( + Path(config_path) if config_path else self.DEFAULT_CONFIG_PATH + ) + self._platforms: dict[str, PlatformConfig] = {} + self._models: dict[str, ModelConfig] = {} + self._task_defaults: dict[str, str] = {} + self._last_modified = 0 + self._load() + + def _load(self): + """加载配置文件""" + if not self.config_path.exists(): + logger.warning(f"配置文件不存在: {self.config_path},使用默认配置") + self._load_defaults() + return + + try: + with open(self.config_path, encoding="utf-8") as f: + if YAML_AVAILABLE: + config = yaml.safe_load(f) + else: + # 备选:使用 JSON + import json + + config = json.load(f) + + self._parse_config(config) + self._last_modified = self.config_path.stat().st_mtime + logger.info( + f"已加载模型配置: {len(self._platforms)} 平台, {len(self._models)} 模型" + ) + + except Exception as e: + logger.error(f"加载配置文件失败: {e},使用默认配置") + self._load_defaults() + + def _parse_config(self, config: dict): + """解析配置(仅解析模型配置,API Key 从 Settings 读取)""" + # 解析平台 + platforms_data = config.get("platforms", {}) + for pid, pdata in platforms_data.items(): + self._platforms[pid] = PlatformConfig( + id=pid, + name=pdata.get("name", pid), + provider=pdata.get("provider", pid), + priority=pdata.get("priority", 100), + base_url=pdata.get("base_url", ""), + ) + + # 解析模型 + models_data = config.get("models", {}) + for mid, mdata in models_data.items(): + self._models[mid] = ModelConfig( + id=mid, + platform_id=mdata.get("platform_id", ""), + model_name=mdata.get("model_name", mid), + display_name=mdata.get("display_name", mid), + capabilities=mdata.get("capabilities", []), + default_params=mdata.get("default_params", {}), + is_enabled=mdata.get("is_enabled", True), + cost_per_1k_input=mdata.get("cost_per_1k_input", 0.0), + cost_per_1k_output=mdata.get("cost_per_1k_output", 0.0), + max_tokens_limit=mdata.get("max_tokens_limit", 4096), + ) + + # 解析任务默认映射 + self._task_defaults = config.get("task_defaults", {}) + + def _load_defaults(self): + """加载默认配置""" + self._platforms = { + "mock": PlatformConfig( + id="mock", + name="Mock 测试平台", + provider="mock", + priority=999, + ) + } + self._models = { + "mock-model": ModelConfig( + id="mock-model", + platform_id="mock", + model_name="mock-model", + display_name="Mock 测试模型", + capabilities=["script", "polish", "chat"], + ) + } + self._task_defaults = { + "script": "mock-model", + "polish": "mock-model", + "chat": "mock-model", + } + + def reload(self): + """重新加载配置(如果文件有更新)""" + if self.config_path.exists(): + current_mtime = self.config_path.stat().st_mtime + if current_mtime > self._last_modified: + logger.info("配置文件已更新,重新加载") + self._load() + return True + return False + + # ============== 查询方法 ============== + + def get_platform(self, platform_id: str) -> PlatformConfig | None: + """获取平台配置""" + return self._platforms.get(platform_id) + + def get_all_platforms(self) -> list[PlatformConfig]: + """获取所有平台(按优先级排序)""" + return sorted(self._platforms.values(), key=lambda p: p.priority) + + def get_model(self, model_id: str) -> ModelConfig | None: + """获取模型配置""" + return self._models.get(model_id) + + def get_all_models(self) -> list[ModelConfig]: + """获取所有模型""" + return list(self._models.values()) + + def get_enabled_models(self) -> list[ModelConfig]: + """获取启用的模型""" + return [m for m in self._models.values() if m.is_enabled] + + def get_models_by_capability(self, capability: str) -> list[ModelConfig]: + """根据能力获取模型""" + return [ + m + for m in self._models.values() + if m.is_enabled and capability in m.capabilities + ] + + def get_models_by_platform(self, platform_id: str) -> list[ModelConfig]: + """根据平台获取模型""" + return [ + m + for m in self._models.values() + if m.platform_id == platform_id and m.is_enabled + ] + + def get_default_model_for_task(self, task_type: str) -> str | None: + """获取任务类型的默认模型 ID""" + return self._task_defaults.get(task_type) + + def set_default_model_for_task(self, task_type: str, model_id: str): + """设置任务类型的默认模型(内存中,不保存到文件)""" + if model_id in self._models: + self._task_defaults[task_type] = model_id + + +# 全局配置加载器实例 +_config_loader: AIModelConfigLoader | None = None + + +def get_config_loader() -> AIModelConfigLoader: + """获取全局配置加载器""" + global _config_loader + if _config_loader is None: + _config_loader = AIModelConfigLoader() + return _config_loader + + +def reload_config() -> bool: + """重新加载配置""" + loader = get_config_loader() + return loader.reload() diff --git a/python-api/app/core/exceptions.py b/python-api/app/core/exceptions.py new file mode 100644 index 0000000..22b9f2e --- /dev/null +++ b/python-api/app/core/exceptions.py @@ -0,0 +1,89 @@ +""" +自定义异常类 +============ +""" + +from fastapi import HTTPException, status + + +class AppException(HTTPException): + """应用基础异常""" + + def __init__( + self, + status_code: int, + message: str = "操作失败", + detail: dict | None = None, + ): + super().__init__(status_code=status_code, detail=detail or {}) + self.message = message + + +class NotFoundException(AppException): + """资源不存在""" + + def __init__(self, message: str = "资源不存在"): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + message=message, + ) + + +class ValidationException(AppException): + """参数验证失败""" + + def __init__(self, message: str = "参数验证失败"): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + message=message, + ) + + +class UnauthorizedException(AppException): + """未授权""" + + def __init__(self, message: str = "未授权,请先登录"): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + message=message, + ) + + +class ForbiddenException(AppException): + """禁止访问""" + + def __init__(self, message: str = "无权访问该资源"): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + message=message, + ) + + +class BusinessException(AppException): + """业务逻辑错误""" + + def __init__(self, message: str = "业务操作失败"): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + message=message, + ) + + +class ModelUnavailableException(AppException): + """AI 模型不可用""" + + def __init__(self, message: str = "AI 模型服务暂时不可用"): + super().__init__( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + message=message, + ) + + +class TaskFailedException(AppException): + """异步任务执行失败""" + + def __init__(self, message: str = "任务执行失败"): + super().__init__( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message=message, + ) diff --git a/python-api/app/core/redis_client.py b/python-api/app/core/redis_client.py new file mode 100644 index 0000000..819f10d --- /dev/null +++ b/python-api/app/core/redis_client.py @@ -0,0 +1,41 @@ +""" +Redis 客户端 +============ +全局 Redis 连接,供 Scheduler 和 RateLimiter 使用 +""" + +from redis.asyncio import Redis + +from app.config import get_settings + +# 全局客户端(懒加载) +_redis_client: Redis | None = None + + +def get_redis_client() -> Redis: + """获取或创建 Redis 客户端""" + global _redis_client + if _redis_client is None: + settings = get_settings() + + # 构建连接参数 + client_kwargs = { + "host": settings.REDIS_HOST, + "port": settings.REDIS_PORT, + "db": settings.REDIS_DB, + "decode_responses": True, + } + + # 有密码时添加 + if settings.REDIS_PASSWORD: + client_kwargs["password"] = settings.REDIS_PASSWORD + + _redis_client = Redis(**client_kwargs) + + return _redis_client + + +def init_redis_client(redis: Redis) -> None: + """初始化全局客户端(用于测试)""" + global _redis_client + _redis_client = redis diff --git a/python-api/app/core/security.py b/python-api/app/core/security.py new file mode 100644 index 0000000..98cf984 --- /dev/null +++ b/python-api/app/core/security.py @@ -0,0 +1,65 @@ +""" +安全工具 - JWT Token 生成与验证 +=============================== +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from jose import JWTError, jwt + +from app.config import get_settings + +settings = get_settings() + + +def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + """ + 创建 JWT 访问令牌 + + Args: + data: 要编码到 Token 中的数据(通常包含 user_id) + expires_delta: 过期时间偏移量,默认使用配置中的设置 + + Returns: + JWT Token 字符串 + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.now(UTC) + expires_delta + else: + expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + + encoded_jwt = jwt.encode( + to_encode, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM, + ) + + return encoded_jwt + + +def verify_token(token: str) -> dict[str, Any | None]: + """ + 验证 JWT Token + + Args: + token: JWT Token 字符串 + + Returns: + 解码后的 payload,如果验证失败返回 None + """ + try: + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) + return payload + except JWTError: + return None diff --git a/python-api/app/core/token_manager.py b/python-api/app/core/token_manager.py new file mode 100644 index 0000000..51d27cf --- /dev/null +++ b/python-api/app/core/token_manager.py @@ -0,0 +1,436 @@ +""" +Token 管理器 - 通用 API 认证 Token 缓存与自动刷新 + +支持: +- JWT Token(如 KlingAI) +- OAuth2 Access Token +- 自定义 Token 类型 + +特性: +- 线程/协程安全的 token 缓存 +- 自动刷新(带安全边界) +- 后台预热机制 +- 支持多 Provider 实例隔离 +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Protocol + +logger = logging.getLogger(__name__) + + +@dataclass +class TokenInfo: + """Token 信息容器""" + + token: str + expires_at: float # 过期时间戳(秒) + token_type: str = "Bearer" + extra_data: dict[str, Any] = field(default_factory=dict) + + @property + def is_expired(self) -> bool: + """是否已过期""" + return time.time() >= self.expires_at + + @property + def expires_in(self) -> float: + """剩余有效时间(秒)""" + return max(0, self.expires_at - time.time()) + + def is_near_expiry(self, safety_margin: float = 300) -> bool: + """ + 是否接近过期(需要刷新) + + Args: + safety_margin: 安全边界(秒),默认5分钟 + """ + return time.time() >= (self.expires_at - safety_margin) + + +class TokenGenerator(Protocol): + """Token 生成函数协议""" + + async def __call__(self) -> TokenInfo: + """生成/获取新的 token""" + ... + + +class BaseTokenStrategy(ABC): + """Token 生成策略基类""" + + @abstractmethod + async def generate(self) -> TokenInfo: + """生成新的 token""" + pass + + @abstractmethod + def get_cache_key(self) -> str: + """获取缓存标识(用于多实例隔离)""" + pass + + +class JWTTokenStrategy(BaseTokenStrategy): + """JWT Token 生成策略(用于 KlingAI 等)""" + + def __init__( + self, + access_key: str, + secret_key: str, + expires_in: int = 1800, + algorithm: str = "HS256", + token_type: str = "JWT", + ): + self.access_key = access_key + self.secret_key = secret_key + self.expires_in = expires_in # 默认30分钟 + self.algorithm = algorithm + self.token_type = token_type + + async def generate(self) -> TokenInfo: + """生成 JWT Token""" + from jose import jwt + + headers = {"alg": self.algorithm, "typ": self.token_type} + current_time = int(time.time()) + payload = { + "iss": self.access_key, + "exp": current_time + self.expires_in, + "nbf": current_time - 5, + } + + token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm, headers=headers) + + return TokenInfo( + token=token, + expires_at=current_time + self.expires_in, + token_type="Bearer", + ) + + def get_cache_key(self) -> str: + """缓存标识:access_key 的 hash""" + return f"jwt:{self.access_key[:8]}" + + +class OAuth2TokenStrategy(BaseTokenStrategy): + """OAuth2 Token 生成策略""" + + def __init__( + self, + client_id: str, + client_secret: str, + token_url: str, + scope: str | None = None, + extra_params: dict[str, Any] | None = None, + ): + self.client_id = client_id + self.client_secret = client_secret + self.token_url = token_url + self.scope = scope + self.extra_params = extra_params or {} + + async def generate(self) -> TokenInfo: + """从 OAuth2 服务器获取 token""" + import httpx + + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + **self.extra_params, + } + if self.scope: + data["scope"] = self.scope + + async with httpx.AsyncClient() as client: + response = await client.post(self.token_url, data=data) + response.raise_for_status() + result = response.json() + + access_token = result["access_token"] + expires_in = result.get("expires_in", 3600) + token_type = result.get("token_type", "Bearer") + + return TokenInfo( + token=access_token, + expires_at=time.time() + expires_in, + token_type=token_type, + extra_data={ + k: v + for k, v in result.items() + if k not in ["access_token", "expires_in", "token_type"] + }, + ) + + def get_cache_key(self) -> str: + """缓存标识:client_id + token_url 的 hash""" + return f"oauth2:{self.client_id[:8]}:{hash(self.token_url) % 10000}" + + +class TokenManager: + """ + Token 管理器 - 单例模式,全局统一管理所有 token + + 使用示例: + # JWT 方式(KlingAI) + strategy = JWTTokenStrategy(access_key="xxx", secret_key="yyy") + token = await TokenManager.get_instance().get_token(strategy) + + # OAuth2 方式 + strategy = OAuth2TokenStrategy( + client_id="xxx", + client_secret="yyy", + token_url="https://api.example.com/oauth2/token" + ) + token = await TokenManager.get_instance().get_token(strategy) + """ + + _instance: TokenManager | None = None + _lock: asyncio.Lock | None = None + + def __new__(cls) -> TokenManager: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + @classmethod + def get_instance(cls) -> TokenManager: + """获取单例实例""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + if self._initialized: + return + + # token 缓存: {cache_key: TokenInfo} + self._tokens: dict[str, TokenInfo] = {} + + # 刷新锁: {cache_key: asyncio.Lock} + self._refresh_locks: dict[str, asyncio.Lock] = {} + + # 全局锁,用于创建新的 refresh_lock + self._global_lock = asyncio.Lock() + + # 后台刷新任务 + self._background_tasks: set[asyncio.Task] = set() + + # 预热配置 + self._safety_margin = 300 # 提前5分钟刷新 + self._preemptive_refresh = True # 启用预热机制 + + self._initialized = True + + async def get_token( + self, + strategy: BaseTokenStrategy, + force_refresh: bool = False, + ) -> TokenInfo: + """ + 获取有效的 token + + Args: + strategy: Token 生成策略 + force_refresh: 强制刷新(忽略缓存) + + Returns: + TokenInfo: 有效的 token 信息 + """ + cache_key = strategy.get_cache_key() + + # 检查缓存 + if not force_refresh and cache_key in self._tokens: + token_info = self._tokens[cache_key] + if not token_info.is_near_expiry(self._safety_margin): + logger.debug(f"Token cache hit for {cache_key}") + return token_info + + # 需要刷新 token + return await self._refresh_token(strategy) + + async def get_token_string( + self, + strategy: BaseTokenStrategy, + force_refresh: bool = False, + ) -> str: + """ + 获取 token 字符串(快捷方法) + + Returns: + str: token 字符串(带 Bearer 前缀) + """ + token_info = await self.get_token(strategy, force_refresh) + return f"{token_info.token_type} {token_info.token}" + + async def _refresh_token(self, strategy: BaseTokenStrategy) -> TokenInfo: + """ + 刷新 token(带并发控制) + + 使用双重检查锁定模式,确保并发请求只触发一次刷新 + """ + cache_key = strategy.get_cache_key() + + # 获取或创建该 cache_key 专用的刷新锁 + async with self._global_lock: + if cache_key not in self._refresh_locks: + self._refresh_locks[cache_key] = asyncio.Lock() + + refresh_lock = self._refresh_locks[cache_key] + + async with refresh_lock: + # 双重检查:等待锁之后,可能其他协程已经刷新过了 + if cache_key in self._tokens: + token_info = self._tokens[cache_key] + if not token_info.is_near_expiry(self._safety_margin): + logger.debug(f"Token refreshed by another task for {cache_key}") + return token_info + + # 执行刷新 + logger.info(f"Refreshing token for {cache_key}") + try: + new_token = await strategy.generate() + self._tokens[cache_key] = new_token + + # 启动后台预热任务 + if self._preemptive_refresh: + self._schedule_preemptive_refresh(strategy, new_token) + + logger.info( + f"Token refreshed successfully for {cache_key}, expires in {new_token.expires_in:.0f}s" + ) + return new_token + + except Exception as e: + logger.error(f"Failed to refresh token for {cache_key}: {e}") + # 如果刷新失败但缓存的 token 还能用,返回缓存的 + if cache_key in self._tokens: + cached = self._tokens[cache_key] + if not cached.is_expired: + logger.warning( + f"Using expired cache for {cache_key} due to refresh failure" + ) + return cached + raise + + def _schedule_preemptive_refresh(self, strategy: BaseTokenStrategy, token_info: TokenInfo): + """ + 调度后台预热刷新任务 + + 在 token 即将过期前自动刷新,避免请求时等待 + """ + cache_key = strategy.get_cache_key() + + # 计算预热时间(token 过期前 safety_margin * 2) + refresh_at = token_info.expires_at - self._safety_margin * 2 + delay = max(0, refresh_at - time.time()) + + async def _refresh_task(): + await asyncio.sleep(delay) + try: + logger.info(f"Preemptive token refresh for {cache_key}") + await self._refresh_token(strategy) + except Exception as e: + logger.error(f"Preemptive refresh failed for {cache_key}: {e}") + + # 创建后台任务 + task = asyncio.create_task(_refresh_task()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + logger.debug(f"Scheduled preemptive refresh for {cache_key} in {delay:.0f}s") + + async def invalidate(self, strategy: BaseTokenStrategy) -> bool: + """ + 使缓存失效 + + Returns: + bool: 是否成功删除 + """ + cache_key = strategy.get_cache_key() + if cache_key in self._tokens: + del self._tokens[cache_key] + logger.info(f"Token cache invalidated for {cache_key}") + return True + return False + + def clear(self): + """清除所有 token 缓存""" + self._tokens.clear() + logger.info("All token caches cleared") + + def get_stats(self) -> dict[str, Any]: + """获取缓存统计信息""" + now = time.time() + stats = { + "total_cached": len(self._tokens), + "active_tasks": len(self._background_tasks), + "tokens": {}, + } + + for key, token_info in self._tokens.items(): + stats["tokens"][key] = { + "expires_in": token_info.expires_in, + "is_expired": token_info.is_expired, + "is_near_expiry": token_info.is_near_expiry(self._safety_margin), + } + + return stats + + +# 便捷函数 + + +async def get_jwt_token( + access_key: str, + secret_key: str, + expires_in: int = 1800, + algorithm: str = "HS256", +) -> TokenInfo: + """ + 获取 JWT Token(使用全局 TokenManager) + + 示例: + token_info = await get_jwt_token("access_key", "secret_key") + headers = {"Authorization": f"Bearer {token_info.token}"} + """ + strategy = JWTTokenStrategy( + access_key=access_key, + secret_key=secret_key, + expires_in=expires_in, + algorithm=algorithm, + ) + return await TokenManager.get_instance().get_token(strategy) + + +async def get_oauth2_token( + client_id: str, + client_secret: str, + token_url: str, + scope: str | None = None, +) -> TokenInfo: + """ + 获取 OAuth2 Token(使用全局 TokenManager) + + 示例: + token_info = await get_oauth2_token( + client_id="xxx", + client_secret="yyy", + token_url="https://api.example.com/oauth2/token" + ) + headers = {"Authorization": f"Bearer {token_info.token}"} + """ + strategy = OAuth2TokenStrategy( + client_id=client_id, + client_secret=client_secret, + token_url=token_url, + scope=scope, + ) + return await TokenManager.get_instance().get_token(strategy) diff --git a/python-api/app/core/token_manager_example.py b/python-api/app/core/token_manager_example.py new file mode 100644 index 0000000..7c95586 --- /dev/null +++ b/python-api/app/core/token_manager_example.py @@ -0,0 +1,170 @@ +""" +TokenManager 使用示例 + +展示如何在 Provider 中使用 TokenManager 来管理认证 Token。 +""" + +import asyncio +import logging + +from app.core.token_manager import ( + JWTTokenStrategy, + OAuth2TokenStrategy, + TokenManager, + get_jwt_token, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def example_jwt(): + """JWT Token 示例(KlingAI 模式)""" + print("=" * 60) + print("JWT Token 示例 (KlingAI)") + print("=" * 60) + + # 方法1: 使用便捷函数(推荐简单场景) + try: + token_info = await get_jwt_token( + access_key="test_access_key", + secret_key="test_secret_key", + ) + print(f"Token: {token_info.token[:50]}...") + print(f"Expires in: {token_info.expires_in:.0f} seconds") + print(f"Is expired: {token_info.is_expired}") + except Exception as e: + print(f"JWT generation failed (expected in demo): {e}") + + # 方法2: 使用 TokenManager + Strategy(推荐 Provider 集成) + strategy = JWTTokenStrategy( + access_key="your_access_key", + secret_key="your_secret_key", + expires_in=1800, # 30分钟 + ) + + # 第一次获取会生成新 token + token1 = await TokenManager.get_instance().get_token(strategy) + print(f"\nFirst token: {token1.token[:30]}...") + + # 第二次获取会命中缓存(如果未过期) + token2 = await TokenManager.get_instance().get_token(strategy) + print(f"Second token: {token2.token[:30]}...") + print(f"Same token: {token1.token == token2.token}") + + # 查看缓存统计 + stats = TokenManager.get_instance().get_stats() + print(f"\nCache stats: {stats}") + + +async def example_oauth2(): + """OAuth2 Token 示例""" + print("\n" + "=" * 60) + print("OAuth2 Token 示例") + print("=" * 60) + + strategy = OAuth2TokenStrategy( + client_id="your_client_id", + client_secret="your_client_secret", + token_url="https://api.example.com/oauth2/token", + scope="read write", + ) + + print("OAuth2 strategy created") + print(f"Cache key: {strategy.get_cache_key()}") + + +async def example_provider_integration(): + """Provider 集成示例""" + print("\n" + "=" * 60) + print("Provider 集成示例") + print("=" * 60) + + # 这是一个模拟的 Provider 类 + class ExampleProvider: + def __init__(self, access_key: str, secret_key: str): + self.access_key = access_key + self.secret_key = secret_key + self._token_strategy = JWTTokenStrategy( + access_key=access_key, + secret_key=secret_key, + expires_in=1800, + ) + + async def _get_headers(self) -> dict[str, str]: + """获取带认证的请求头""" + token_info = await TokenManager.get_instance().get_token(self._token_strategy) + return { + "Authorization": f"Bearer {token_info.token}", + "Content-Type": "application/json", + } + + async def make_request(self): + """模拟 API 请求""" + headers = await self._get_headers() + print(f"Request headers: {headers}") + # 实际使用时: await session.post(url, headers=headers, ...) + + provider = ExampleProvider("access_key_123", "secret_key_456") + await provider.make_request() + + +async def example_concurrent_requests(): + """并发请求示例 - 测试 token 刷新时的并发安全""" + print("\n" + "=" * 60) + print("并发请求示例") + print("=" * 60) + + strategy = JWTTokenStrategy( + access_key="concurrent_test_key", + secret_key="concurrent_test_secret", + expires_in=1800, + ) + + async def request_task(task_id: int): + """模拟单个请求""" + token_info = await TokenManager.get_instance().get_token(strategy) + print(f"Task {task_id}: got token (expires in {token_info.expires_in:.0f}s)") + return token_info + + # 并发10个请求,应该只触发一次 token 生成 + print("Launching 10 concurrent requests...") + results = await asyncio.gather(*[request_task(i) for i in range(10)]) + + # 验证所有请求拿到的是同一个 token + tokens = [r.token for r in results] + unique_tokens = set(tokens) + print(f"\nTotal requests: {len(tokens)}") + print(f"Unique tokens generated: {len(unique_tokens)}") + print(f"Concurrent safety: {'✓ PASS' if len(unique_tokens) == 1 else '✗ FAIL'}") + + +async def example_stats(): + """查看 TokenManager 统计信息""" + print("\n" + "=" * 60) + print("TokenManager 统计") + print("=" * 60) + + manager = TokenManager.get_instance() + stats = manager.get_stats() + + print(f"Total cached tokens: {stats['total_cached']}") + print(f"Active background tasks: {stats['active_tasks']}") + print(f"Token details: {stats['tokens']}") + + +async def main(): + """运行所有示例""" + await example_jwt() + await example_oauth2() + await example_provider_integration() + await example_concurrent_requests() + await example_stats() + + print("\n" + "=" * 60) + print("所有示例完成") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python-api/app/crud/__init__.py b/python-api/app/crud/__init__.py new file mode 100644 index 0000000..fa5ca31 --- /dev/null +++ b/python-api/app/crud/__init__.py @@ -0,0 +1,19 @@ +""" +CRUD 模块 +======== + +统一导出所有 CRUD 实例,方便导入使用。 + +使用示例: + from app.crud import user + + user_obj = await user.get(db, id="xxx") +""" + +from app.crud.model_usage import model_usage_log +from app.crud.user import user + +__all__ = [ + "user", + "model_usage_log", +] diff --git a/python-api/app/crud/avatar.py b/python-api/app/crud/avatar.py new file mode 100644 index 0000000..7cdd769 --- /dev/null +++ b/python-api/app/crud/avatar.py @@ -0,0 +1,104 @@ +""" +Avatar CRUD 操作 +================ + +形象克隆记录的数据访问层。 +""" + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud.base import CRUDBase +from app.models.avatar import Avatar +from app.schemas.avatar import AvatarCreate, AvatarUpdate + + +class CRUDAvatar(CRUDBase[Avatar, AvatarCreate, AvatarUpdate]): + """Avatar 数据访问对象""" + + def __init__(self) -> None: + super().__init__(Avatar) + + async def get_multi_by_user( + self, db: AsyncSession, *, user_id: str, skip: int = 0, limit: int = 100 + ) -> list[Avatar]: + """获取用户的形象列表(排除已软删除)""" + result = await db.execute( + select(Avatar) + .where(Avatar.user_id == user_id) + .where(Avatar.deleted_at.is_(None)) + .offset(skip) + .limit(limit) + .order_by(Avatar.created_at.desc()) + ) + return list(result.scalars().all()) + + async def soft_delete(self, db: AsyncSession, *, id: str, commit: bool = True) -> Avatar | None: + """软删除形象记录""" + obj = await self.get(db, id) + if obj: + obj.deleted_at = datetime.now(UTC) + if commit: + await db.commit() + await db.refresh(obj) + else: + await db.flush() + return obj + + async def get_stuck_tasks( + self, + db: AsyncSession, + processing_statuses: list[str], + timeout_minutes: int = 30, + limit: int = 100, + ) -> list[Avatar]: + """获取卡住的任务(超过指定时间未更新的处理中任务) + + Args: + db: 数据库会话 + processing_statuses: 需要检查的处理中状态列表 + timeout_minutes: 超时时间(分钟) + limit: 最大返回数量 + """ + timeout_threshold = datetime.now(UTC) - timedelta(minutes=timeout_minutes) + + result = await db.execute( + select(Avatar) + .where(Avatar.status.in_(processing_statuses)) + .where(Avatar.deleted_at.is_(None)) + .where(Avatar.updated_at < timeout_threshold) + .limit(limit) + .order_by(Avatar.updated_at.asc()) + ) + return list(result.scalars().all()) + + async def get_by_status_in( + self, + db: AsyncSession, + statuses: list[str], + updated_before: datetime | None = None, + limit: int = 100, + ) -> list[Avatar]: + """根据状态列表查询任务 + + Args: + db: 数据库会话 + statuses: 状态列表 + updated_before: 更新时间早于该时间的记录 + limit: 最大返回数量 + """ + query = select(Avatar).where(Avatar.status.in_(statuses)).where(Avatar.deleted_at.is_(None)) + + if updated_before: + query = query.where(Avatar.updated_at < updated_before) + + query = query.limit(limit).order_by(Avatar.updated_at.asc()) + + result = await db.execute(query) + return list(result.scalars().all()) + + +# 全局单例 +avatar = CRUDAvatar() diff --git a/python-api/app/crud/base.py b/python-api/app/crud/base.py new file mode 100644 index 0000000..ddd3697 --- /dev/null +++ b/python-api/app/crud/base.py @@ -0,0 +1,127 @@ +""" +CRUD 基础类 +========== + +提供通用的数据访问方法,所有业务 CRUD 必须继承此类。 +""" + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.base import BaseModel as AppBaseModel + +ModelType = TypeVar("ModelType", bound=AppBaseModel) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel, default=Any) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel, default=Any) + + +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + """ + 通用 CRUD 基类 + + 所有业务 CRUD 必须继承此类,确保接口统一。 + + 使用示例: + class UserCRUD(CRUDBase[User, UserCreate, UserUpdate]): + def __init__(self): + super().__init__(User) + + # 添加业务特定方法... + + user = UserCRUD() + """ + + def __init__(self, model: type[ModelType]): + """ + Args: + model: SQLAlchemy 模型类 + """ + self.model = model + + async def get(self, db: AsyncSession, id: str) -> ModelType | None: + """根据 ID 获取单个对象""" + result = await db.execute(select(self.model).where(self.model.id == id)) + return result.scalar_one_or_none() + + async def get_multi( + self, db: AsyncSession, *, skip: int = 0, limit: int = 100 + ) -> list[ModelType]: + """获取多个对象(分页)""" + result = await db.execute(select(self.model).offset(skip).limit(limit)) + return list(result.scalars().all()) + + async def create( + self, db: AsyncSession, *, obj_in: CreateSchemaType | dict[str, Any], commit: bool = True + ) -> ModelType: + """创建对象 + + Args: + db: 数据库会话 + obj_in: 对象数据(Pydantic 模型或字典) + commit: 是否自动提交(默认True)。如需在事务中批量操作,设为False由调用方控制提交 + """ + if isinstance(obj_in, BaseModel): + obj_in = obj_in.model_dump(exclude_unset=True) + db_obj = self.model(**obj_in) + db.add(db_obj) + if commit: + await db.commit() + await db.refresh(db_obj) + else: + # 不提交时刷新以获取默认值(如自增ID),但需在事务中 + await db.flush() + await db.refresh(db_obj) + return db_obj + + async def update( + self, + db: AsyncSession, + *, + db_obj: ModelType, + obj_in: UpdateSchemaType | dict[str, Any], + commit: bool = True, + ) -> ModelType: + """更新对象 + + Args: + db: 数据库会话 + db_obj: 数据库对象 + obj_in: 更新数据(Pydantic 模型或字典) + commit: 是否自动提交(默认True)。如需在事务中批量操作,设为False由调用方控制提交 + """ + if isinstance(obj_in, BaseModel): + update_data = obj_in.model_dump(exclude_unset=True) + else: + update_data = obj_in + for field, value in update_data.items(): + if hasattr(db_obj, field) and value is not None: + setattr(db_obj, field, value) + if commit: + await db.commit() + await db.refresh(db_obj) + else: + await db.flush() + return db_obj + + async def delete(self, db: AsyncSession, *, id: str, commit: bool = True) -> ModelType | None: + """删除对象 + + Args: + db: 数据库会话 + id: 对象ID + commit: 是否自动提交(默认True)。如需在事务中批量操作,设为False由调用方控制提交 + """ + obj = await self.get(db, id) + if obj: + await db.delete(obj) + if commit: + await db.commit() + return obj + + async def count(self, db: AsyncSession) -> int: + """统计总数""" + result = await db.execute(select(func.count(self.model.id))) + return result.scalar() or 0 diff --git a/python-api/app/crud/model_usage.py b/python-api/app/crud/model_usage.py new file mode 100644 index 0000000..66704c1 --- /dev/null +++ b/python-api/app/crud/model_usage.py @@ -0,0 +1,45 @@ +""" +模型使用日志 CRUD 操作 +====================== + +仅保留使用日志功能,模型配置已迁移到 YAML 文件。 +""" + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud.base import CRUDBase +from app.models.model_usage import ModelUsageLog + + +class ModelUsageLogCRUD(CRUDBase[ModelUsageLog]): + """模型使用日志 CRUD""" + + def __init__(self) -> None: + super().__init__(ModelUsageLog) + + async def get_daily_cost(self, db: AsyncSession, *, date: str) -> float: + """获取某日总成本""" + result = await db.execute( + select(func.sum(ModelUsageLog.cost_cny)).where( + func.date(ModelUsageLog.created_at) == date + ) + ) + return result.scalar() or 0.0 + + async def get_by_user( + self, db: AsyncSession, *, user_id: str, skip: int = 0, limit: int = 100 + ) -> list[ModelUsageLog]: + """获取用户的使用日志""" + result = await db.execute( + select(ModelUsageLog) + .where(ModelUsageLog.user_id == user_id) + .order_by(ModelUsageLog.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return list(result.scalars().all()) + + +# 导出实例 +model_usage_log = ModelUsageLogCRUD() diff --git a/python-api/app/crud/user.py b/python-api/app/crud/user.py new file mode 100644 index 0000000..a5bf3e9 --- /dev/null +++ b/python-api/app/crud/user.py @@ -0,0 +1,51 @@ +""" +用户 CRUD 操作 +============== + +用户认证相关的数据访问。 +""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud.base import CRUDBase +from app.models.user import User + + +class UserCRUD(CRUDBase[User]): + """用户数据访问对象""" + + def __init__(self) -> None: + super().__init__(User) + + async def get_by_mobile(self, db: AsyncSession, *, mobile: str) -> User | None: + """根据手机号获取用户""" + result = await db.execute(select(User).where(User.mobile == mobile)) + return result.scalar_one_or_none() + + async def get_or_create_by_mobile( + self, db: AsyncSession, *, mobile: str, nickname: str | None = None + ) -> User: + """ + 根据手机号获取或创建用户 + + Returns: + 已存在或新创建的用户 + """ + user = await self.get_by_mobile(db, mobile=mobile) + + if user is None: + # 创建新用户 + user = await self.create( + db, + obj_in={ + "mobile": mobile, + "nickname": nickname or f"用户_{mobile[-4:]}", + }, + ) + + return user + + +# 导出实例 +user = UserCRUD() diff --git a/python-api/app/db/__init__.py b/python-api/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python-api/app/db/session.py b/python-api/app/db/session.py new file mode 100644 index 0000000..7d903fe --- /dev/null +++ b/python-api/app/db/session.py @@ -0,0 +1,61 @@ +""" +SQLAlchemy 数据库配置 +==================== + +统一使用 PostgreSQL + 异步模式。 +""" + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import declarative_base + +from app.config import get_settings + +Base = declarative_base() + +settings = get_settings() + +async_engine = create_async_engine( + settings.DATABASE_URL, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, + echo=settings.DEBUG, +) + +AsyncSessionLocal = async_sessionmaker( + async_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +async def get_db(): + """获取异步数据库 Session + + 注意:commit 由调用方(API层或Service层)控制,不在此自动提交 + """ + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db(): + """初始化数据库""" + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def close_db(): + """关闭数据库连接""" + await async_engine.dispose() diff --git a/python-api/app/main.py b/python-api/app/main.py new file mode 100644 index 0000000..a72b7fc --- /dev/null +++ b/python-api/app/main.py @@ -0,0 +1,180 @@ +""" +FastAPI 应用入口 +================ +""" + +import logging +import sys +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api.v1.router import api_router +from app.config import get_settings +from app.db.session import close_db, init_db +from app.schemas.common import ApiResponse + +settings = get_settings() + +# 配置日志 - 同时输出到控制台和文件 +log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +log_level = getattr(logging, settings.LOG_LEVEL) + +# 创建日志目录(在用户文档目录下) +log_dir = Path.home() / "Documents" / "Meijiaka" / "logs" +log_dir.mkdir(parents=True, exist_ok=True) + +# 日志文件名按日期 +log_file = log_dir / f"api_{datetime.now().strftime('%Y%m%d')}.log" + +# 配置根日志记录器 +logging.basicConfig( + level=log_level, + format=log_format, + handlers=[ + logging.StreamHandler(sys.stdout), # 控制台输出 + logging.FileHandler(log_file, encoding="utf-8", mode="a"), # 文件输出 + ], +) +logger = logging.getLogger(__name__) +logger.info(f"日志文件位置: {log_file}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + 应用生命周期管理 + + - 启动时:初始化数据库、加载模型配置 + - 关闭时:清理资源 + """ + logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}") + + # 开发环境自动创建表 + if settings.DEBUG and settings.ENV == "development": + logger.info("Initializing database tables...") + try: + # 确保所有模型已注册到 metadata + from app.models import Avatar, ModelUsageLog, User # noqa: F401 + + await init_db() + logger.info("Database tables initialized") + except Exception as e: + logger.warning(f"Database initialization skipped: {e}") + + # 加载 AI 模型配置(从 YAML 文件) + try: + from app.core.config_loader import get_config_loader + + config_loader = get_config_loader() + platforms_count = len(config_loader.get_all_platforms()) + models_count = len(config_loader.get_enabled_models()) + + logger.info(f"Loaded {platforms_count} platforms, {models_count} models from config file") + except Exception as e: + logger.warning(f"Failed to load models from config: {e}") + + yield + + # 关闭时清理 + logger.info("Shutting down...") + await close_db() + logger.info("Cleanup complete") + + +def create_app() -> FastAPI: + """创建 FastAPI 应用实例""" + + app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="美家卡智影 - AI 视频创作后端 API", + docs_url="/docs" if settings.DEBUG else None, + redoc_url="/redoc" if settings.DEBUG else None, + lifespan=lifespan, + ) + + # CORS 配置 + # 开发环境下允许所有来源,避免跨域问题 + allow_origins = ["*"] if settings.DEBUG else settings.cors_origins_list + app.add_middleware( + CORSMiddleware, + allow_origins=allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 注册路由 + app.include_router(api_router, prefix="/api/v1") + + # 全局异常处理(统一返回 ApiResponse 格式) + @app.exception_handler(Exception) + async def global_exception_handler(request, exc): + """全局异常捕获""" + logger.exception("Unhandled exception") + return JSONResponse( + status_code=500, + content={ + "code": 500, + "message": "服务器内部错误", + "data": None, + "detail": {"error": str(exc)} if settings.DEBUG else None, + }, + ) + + # 健康检查 + @app.get("/health", tags=["System"]) + async def health_check(): + """服务健康检查""" + return ApiResponse( + code=200, + data={ + "status": "healthy", + "version": settings.APP_VERSION, + "environment": settings.ENV, + }, + message="服务运行正常", + ) + + # 根路由 + @app.get("/", tags=["System"]) + async def root(): + """API 根路径""" + return ApiResponse( + code=200, + data={ + "name": settings.APP_NAME, + "version": settings.APP_VERSION, + "docs": "/docs" if settings.DEBUG else None, + }, + message="美家卡智影 API 服务", + ) + + return app + + +# 创建应用实例 +app = create_app() + + +def main(): + """入口函数(用于命令行启动)""" + uvicorn.run( + "app.main:app", + host=settings.HOST, + port=settings.PORT, + workers=settings.WORKERS if not settings.DEBUG else 1, + reload=settings.DEBUG, + log_level=settings.LOG_LEVEL.lower(), + ) + + +if __name__ == "__main__": + main() +# test diff --git a/python-api/app/models/__init__.py b/python-api/app/models/__init__.py new file mode 100644 index 0000000..3d8893f --- /dev/null +++ b/python-api/app/models/__init__.py @@ -0,0 +1,20 @@ +""" +模型模块 + +所有 SQLAlchemy 模型定义。 + +注意:AIModel/AIPlatform 已迁移到 YAML 配置 (config/ai_models.yaml) +""" + +from app.models.avatar import Avatar +from app.models.base import BaseModel +from app.models.model_usage import ModelUsageLog +from app.models.user import User + +# 当前可用的模型 +__all__ = [ + "Avatar", + "BaseModel", + "ModelUsageLog", + "User", +] diff --git a/python-api/app/models/avatar.py b/python-api/app/models/avatar.py new file mode 100644 index 0000000..29610d9 --- /dev/null +++ b/python-api/app/models/avatar.py @@ -0,0 +1,140 @@ +""" +Avatar 形象克隆模型 +================== + +存储用户克隆形象的信息,作为本地 localStorage 的云端备份。 +""" + +from datetime import UTC, datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.schemas.enums import AvatarCloneStatus + + +class Avatar(Base): + """ + 形象克隆记录表 + + 用于备份用户在本地创建的克隆形象,支持换机恢复和客服排查。 + """ + + __tablename__ = "avatars" + + # 主键:本地生成的唯一标识(与 Kling element_id 无关) + id: Mapped[str] = mapped_column( + String(64), + primary_key=True, + comment="本地形象唯一标识(如 avt_xxx)", + ) + + # 关联用户(外键,对应 users.id) + user_id: Mapped[str] = mapped_column( + UUID(as_uuid=False), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="关联用户 ID", + ) + + # 形象展示名称 + name: Mapped[str] = mapped_column( + String(64), + nullable=False, + comment="形象展示名称", + ) + + # 供应商标识 + provider: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="kling", + comment="供应商标识: kling", + ) + + # Kling 自定义音色 ID(创建成功后回填) + voice_id: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + comment="Kling 自定义音色 ID", + ) + + # 供应商主体 ID(创建成功后回填,用于调用 omni-video API) + provider_element_id: Mapped[int | None] = mapped_column( + BigInteger, + nullable=True, + comment="供应商主体 ID(数字类型,调用 API 时使用)", + ) + + # 供应商任务 ID(用于客服追溯) + provider_voice_job_id: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + index=True, + comment="供应商自定义音色任务 ID", + ) + + provider_element_job_id: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + index=True, + comment="供应商主体创建任务 ID", + ) + + # 资源地址 + video_url: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="原始人物视频 URL", + ) + + trial_url: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="音色试听音频 URL", + ) + + # 状态机 + status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default=AvatarCloneStatus.PENDING.value, + comment="状态: pending/voice_processing/voice_failed/element_processing/element_failed/succeed/timeout", + ) + + # 失败原因(用户可读) + fail_reason: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="失败原因(中文可读)", + ) + + # 软删除标记 + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="软删除时间,NULL 表示未删除", + ) + + # 时间戳 + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + comment="记录创建时间", + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + nullable=False, + comment="记录更新时间", + ) + + def to_dict(self) -> dict: + """转换为字典(用于序列化)""" + return {column.name: getattr(self, column.name) for column in self.__table__.columns} diff --git a/python-api/app/models/base.py b/python-api/app/models/base.py new file mode 100644 index 0000000..516bd1b --- /dev/null +++ b/python-api/app/models/base.py @@ -0,0 +1,49 @@ +""" +基础模型定义 +============ +""" + +import uuid +from datetime import UTC, datetime + +from sqlalchemy import DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class BaseModel(Base): + """ + 基础模型 - 所有模型继承此类 + + 提供: + - UUID 主键(自动生成) + - 创建时间 + - 更新时间 + """ + + __abstract__ = True + + id: Mapped[str] = mapped_column( + UUID(as_uuid=False), + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + nullable=False, + ) + + def to_dict(self) -> dict: + """转换为字典(用于序列化)""" + return {column.name: getattr(self, column.name) for column in self.__table__.columns} diff --git a/python-api/app/models/model_usage.py b/python-api/app/models/model_usage.py new file mode 100644 index 0000000..ec401c1 --- /dev/null +++ b/python-api/app/models/model_usage.py @@ -0,0 +1,62 @@ +""" +AI 模型使用日志模型 +================== + +存储模型调用的使用日志,用于成本统计和监控。 + +模型配置已迁移到 YAML 文件:config/ai_models.yaml +""" + +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Float, Index, Integer, String, Text + +from app.db.session import Base + + +class ModelUsageLog(Base): + """模型使用日志 - 用于成本统计和监控""" + + __tablename__ = "model_usage_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + + # 调用信息 + model_id = Column(String(100), nullable=False) + platform_id = Column(String(50), nullable=False) + + # 调用类型 + task_type = Column(String(50), nullable=False) # script、polish、chat + + # Token 用量 + prompt_tokens = Column(Integer, default=0) + completion_tokens = Column(Integer, default=0) + total_tokens = Column(Integer, default=0) + + # 成本(计算后的人民币) + cost_cny = Column(Float, default=0.0) + + # 性能 + response_time_ms = Column(Integer, nullable=True) # 响应时间 + + # 结果 + success = Column(Boolean, default=True) + error_message = Column(Text, nullable=True) + + # 用户/项目 + user_id = Column(String(50), nullable=True) + project_id = Column(String(50), nullable=True) + + # 时间 + created_at = Column(DateTime, default=datetime.utcnow) + + # 索引定义 + __table_args__ = ( + # 索引:按用户查询使用记录 + Index("ix_model_usage_logs_user_id", "user_id"), + # 索引:按时间查询(用于统计) + Index("ix_model_usage_logs_created_at", "created_at"), + ) + + def __repr__(self): + return f"" diff --git a/python-api/app/models/user.py b/python-api/app/models/user.py new file mode 100644 index 0000000..07d5722 --- /dev/null +++ b/python-api/app/models/user.py @@ -0,0 +1,41 @@ +""" +用户模型 +======== + +采用"手机号 + JWT"的传统认证方案。 +""" + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BaseModel + + +class User(BaseModel): + """用户表""" + + __tablename__ = "users" + + # 手机号,作为登录账号 + mobile: Mapped[str] = mapped_column( + String(20), + unique=True, + index=True, + nullable=False, + comment="手机号", + ) + + nickname: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + comment="用户昵称", + ) + + avatar_url: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="头像 URL", + ) + + def __repr__(self) -> str: + return f"" diff --git a/python-api/app/scheduler/__init__.py b/python-api/app/scheduler/__init__.py new file mode 100644 index 0000000..0fd8e19 --- /dev/null +++ b/python-api/app/scheduler/__init__.py @@ -0,0 +1,6 @@ +""" +统一异步任务调度器 +================== + +统一异步任务调度器(替代原 Celery 架构)。 +""" diff --git a/python-api/app/scheduler/engine.py b/python-api/app/scheduler/engine.py new file mode 100644 index 0000000..02e270c --- /dev/null +++ b/python-api/app/scheduler/engine.py @@ -0,0 +1,121 @@ +""" +Async Engine 核心调度器 +======================= + +驱动所有 Handler 的 Tick 循环,批量查询、批量更新。 +""" + +import asyncio +import logging +from typing import Any + +from app.core.redis_client import get_redis_client +from app.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager + +logger = logging.getLogger(__name__) + + +class AsyncEngine: + """统一异步作业调度引擎""" + + def __init__(self, handlers: list[AsyncHandler] | None = None): + self.redis = get_redis_client() + self.registry = JobRegistry(self.redis) + self.slots = SlotManager(self.redis) + self.handlers: dict[str, AsyncHandler] = {} + if handlers: + for h in handlers: + self.handlers[h.name] = h + + def register(self, handler: AsyncHandler) -> None: + """注册一个 Handler""" + self.handlers[handler.name] = handler + logger.info(f"Registered handler: {handler.name}") + + async def tick(self) -> None: + """执行一次完整的调度 Tick""" + tick_start = asyncio.get_event_loop().time() + + try: + # 1. 加载所有 running 的作业 ID + running_ids = await self.registry.get_running_job_ids() + if not running_ids: + logger.debug("Tick: no running jobs") + return + + # 2. 按 job_type 分组 + jobs_by_type: dict[str, list[Any]] = {} + for job_id in running_ids: + record = await self.registry.get(job_id) + if not record: + await self.registry.remove_running(job_id) + continue + jobs_by_type.setdefault(record.job_type, []).append(record) + + # 3. 并行执行各 Handler 的 tick + results = await asyncio.gather( + *[ + self._safe_tick(handler_name, handler, jobs_by_type.get(handler_name, [])) + for handler_name, handler in self.handlers.items() + ] + ) + + # 4. 收集并应用状态变更 + for changes in results: + if changes: + await self._apply_changes(changes) + + # 5. 清理已结束的作业 + await self._cleanup_finished() + + except Exception: + logger.exception("Scheduler tick failed") + finally: + elapsed = asyncio.get_event_loop().time() - tick_start + logger.debug(f"Tick completed in {elapsed:.2f}s") + + async def _safe_tick( + self, name: str, handler: AsyncHandler, jobs: list[Any] + ) -> list[StateChange]: + """安全执行 Handler tick,捕获异常""" + try: + return await handler.tick(jobs, self.registry, self.slots) + except Exception: + logger.exception(f"Handler tick failed: {name}") + return [] + + async def _apply_changes(self, changes: list[StateChange]) -> None: + """批量应用状态变更到 Redis""" + pipe = self.redis.pipeline() + executed = False + for change in changes: + key, field, value = change.to_redis_command() + pipe.hset(key, field, value) + executed = True + if executed: + await pipe.execute() + + async def _cleanup_finished(self) -> None: + """清理已完成的作业""" + running_ids = await self.registry.get_running_job_ids() + for job_id in running_ids: + record = await self.registry.get(job_id) + if not record: + await self.registry.remove_running(job_id) + continue + if record.status in ("completed", "failed"): + await self.registry.remove_running(job_id) + logger.info(f"Job moved to finished: {job_id} ({record.status})") + + async def run_forever(self, interval: float = 10.0, min_interval: float = 2.0) -> None: + """启动无限 Tick 循环""" + logger.info("Async Engine started") + while True: + tick_start = asyncio.get_event_loop().time() + await self.tick() + elapsed = asyncio.get_event_loop().time() - tick_start + sleep_time = max(interval - elapsed, min_interval) + await asyncio.sleep(sleep_time) diff --git a/python-api/app/scheduler/handlers/__init__.py b/python-api/app/scheduler/handlers/__init__.py new file mode 100644 index 0000000..358d614 --- /dev/null +++ b/python-api/app/scheduler/handlers/__init__.py @@ -0,0 +1,3 @@ +""" +Scheduler Handlers +""" diff --git a/python-api/app/scheduler/handlers/avatar_handler.py b/python-api/app/scheduler/handlers/avatar_handler.py new file mode 100644 index 0000000..d68e0f6 --- /dev/null +++ b/python-api/app/scheduler/handlers/avatar_handler.py @@ -0,0 +1,504 @@ +""" +Avatar 形象克隆处理器 +==================== + +管理 Kling 形象克隆的提交与轮询。 +占用全局槽位:2 + +数据策略:不操作数据库,所有中间状态存储在 Redis 中。 +""" + +import asyncio +import contextlib +import json +import logging +from datetime import UTC, datetime +from typing import Any + +import aiohttp + +from app.ai.providers.klingai_provider import KlingAIProvider +from app.config import get_settings +from app.core.redis_client import get_redis_client +from app.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager +from app.schemas.enums import AvatarCloneStatus + +logger = logging.getLogger(__name__) + +SLOT_KEY = "kling:avatar_slots" +MAX_SLOTS = 2 + +SYSTEM_BUSY_MESSAGE = "系统繁忙,请稍后重试" +SYSTEM_ERROR_MESSAGE = "系统处理异常,请稍后重试或联系客服" + + +def _get_kling_provider() -> KlingAIProvider: + settings = get_settings() + return KlingAIProvider( + config={ + "access_key": settings.KLINGAI_ACCESS_KEY or "", + "secret_key": settings.KLINGAI_SECRET_KEY or "", + } + ) + + +def _translate_voice_error(message: str) -> str: + msg = (message or "").lower() + if "no valid audio" in msg or "audio" in msg or "voice" in msg or "人声" in msg: + return "自定义音色创建失败:视频中没有检测到清晰的人声。请确保上传「有声的人物视频」,且人声干净、无杂音、背景噪音小。" + if "duration" in msg or "时长" in msg: + return "自定义音色创建失败:视频时长不符合要求。请使用 5-30 秒的视频。" + if "format" in msg or "格式" in msg: + return "自定义音色创建失败:视频格式不支持。请使用 MP4 或 MOV 格式。" + if "size" in msg or "大小" in msg or "mb" in msg: + return "自定义音色创建失败:视频文件过大。请压缩至 200MB 以内。" + if "quality" in msg or "质量" in msg: + return "自定义音色创建失败:视频/音频质量不符合要求。请确保画面清晰、人声干净、无强烈背景噪音。" + return f"自定义音色创建失败:{message}。请检查是否上传了符合要求的「有声的人物视频」。" + + +def _translate_element_error(message: str) -> str: + msg = (message or "").lower() + if "duration" in msg or "时长" in msg: + return "主体创建失败:视频时长不符合要求。请使用 3-8 秒的人物特写视频。" + if "resolution" in msg or "height" in msg or "像素" in msg or "720" in msg or "2160" in msg: + return "主体创建失败:视频分辨率不符合要求。请确保视频高度在 720px~2160px 之间。" + if "size" in msg or "大小" in msg or "mb" in msg or "200" in msg: + return "主体创建失败:视频文件过大。请压缩至 200MB 以内。" + if "format" in msg or "格式" in msg or "mp4" in msg or "mov" in msg: + return "主体创建失败:视频格式不支持。请使用 MP4 或 MOV 格式。" + if "face" in msg or "人脸" in msg or "detect" in msg or "主体" in msg: + return "主体创建失败:未能从视频中检测到稳定的人脸。请确保视频为「写实风格的人物正面特写」,人脸清晰、无遮挡、光线充足。" + if "human" in msg or "人形" in msg or "character" in msg or "写实" in msg: + return "主体创建失败:视频内容不符合要求。请确保视频中是「写实风格的真实人物」,非卡通、非动物、非虚拟形象。" + return f"主体创建失败:{message}。请检查视频是否为 3-8 秒、人脸清晰、写实风格的正面人物视频。" + + +def _translate_system_error(error: Exception, step: str) -> tuple[str, str]: + error_str = str(error) + error_type = type(error).__name__ + if isinstance(error, aiohttp.ClientError | asyncio.TimeoutError): + return SYSTEM_BUSY_MESSAGE, f"[{step}] 网络错误: {error_type}: {error_str}" + if "500" in error_str or "503" in error_str or "502" in error_str: + return SYSTEM_BUSY_MESSAGE, f"[{step}] KlingAI 服务错误: {error_type}: {error_str}" + if ( + "rate limit" in error_str.lower() + or "too many requests" in error_str.lower() + or "429" in error_str + ): + return SYSTEM_BUSY_MESSAGE, f"[{step}] API 限流: {error_type}: {error_str}" + return SYSTEM_ERROR_MESSAGE, f"[{step}] 系统错误: {error_type}: {error_str}" + + +async def _update_avatar_state(registry: JobRegistry, avatar_id: str, **fields: Any) -> None: + """更新 Redis 中的 avatar 状态(同时更新 updated_at)""" + fields["updated_at"] = datetime.now(UTC).isoformat() + await registry.update(avatar_id, **fields) + + +class AvatarHandler(AsyncHandler): + name = "avatar_clone" + slot_key = SLOT_KEY + max_slots = MAX_SLOTS + + async def tick( + self, jobs: list[Any], registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + for job in jobs: + job_changes = await self._process_job(job, registry, slots) + changes.extend(job_changes) + return changes + + async def _process_job( + self, job: Any, registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + avatar_id = job.job_id + + # 从 Redis 读取 avatar 状态 + redis = get_redis_client() + state_raw = await redis.hgetall(f"job:{avatar_id}") + if not state_raw: + logger.error(f"Avatar job not found in Redis: {avatar_id}") + _msg = "任务记录丢失,请重新提交" + changes.append(StateChange(job_id=avatar_id, field_path="status", value="failed")) + changes.append(StateChange(job_id=avatar_id, field_path="message", value=_msg)) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_msg)) + return changes + + # 解析 params + params = {} + if "params" in state_raw and state_raw["params"]: + with contextlib.suppress(json.JSONDecodeError): + params = json.loads(state_raw["params"]) + + status = state_raw.get("avatar_status", state_raw.get("status", "")) + provider = _get_kling_provider() + + # 辅助函数:读取字段 + def _f(key: str) -> str: + return state_raw.get(key, "") or "" + + # ---------- pending: 创建音色 ---------- + if status == AvatarCloneStatus.PENDING.value: + slot_id = f"avatar:{avatar_id}" + acquired = await slots.acquire(SLOT_KEY, slot_id, MAX_SLOTS) + if not acquired: + return changes # 槽位已满,等下一轮 + + try: + await _update_avatar_state( + registry, avatar_id, avatar_status=AvatarCloneStatus.VOICE_PROCESSING.value + ) + changes.append( + StateChange( + job_id=avatar_id, field_path="message", value="正在创建自定义音色..." + ) + ) + voice_result = await provider.create_custom_voice( + voice_name=params.get("name", ""), + video_url=params.get("video_url", ""), + ) + voice_task_id = voice_result.get("task_id") + if not voice_task_id: + raise Exception("未返回音色任务 ID") + await _update_avatar_state(registry, avatar_id, provider_voice_job_id=voice_task_id) + logger.info(f"Avatar {avatar_id}: created voice task {voice_task_id}") + except Exception as e: + await slots.release(SLOT_KEY, slot_id) + if isinstance(e, aiohttp.ClientError | asyncio.TimeoutError) or any( + code in str(e) for code in ["500", "503", "502", "429"] + ): + user_msg, cloud_detail = _translate_system_error(e, "voice_create") + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.VOICE_FAILED.value, + fail_reason=user_msg, + ) + logger.error(f"Avatar {avatar_id} voice_create system error: {cloud_detail}") + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=user_msg) + ) + changes.append( + StateChange(job_id=avatar_id, field_path="error", value=user_msg) + ) + else: + _reason = _translate_voice_error(str(e)) + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.VOICE_FAILED.value, + fail_reason=_reason, + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=_reason) + ) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_reason)) + + # ---------- voice_processing: 轮询音色 ---------- + elif status == AvatarCloneStatus.VOICE_PROCESSING.value: + provider_voice_job_id = _f("provider_voice_job_id") + if not provider_voice_job_id: + return changes + try: + result = await provider.get_custom_voice_task(provider_voice_job_id) + kling_status = result.get("task_status", "processing") + logger.info( + f"Avatar {avatar_id}: voice task {provider_voice_job_id} status={kling_status}" + ) + if kling_status == "processing": + changes.append( + StateChange(job_id=avatar_id, field_path="message", value="音色处理中...") + ) + elif kling_status == "succeed": + await slots.release(SLOT_KEY, f"avatar:{avatar_id}") + task_result = result.get("task_result", {}) + voices = task_result.get("voices", []) + voice_id = None + trial_url = None + if voices: + voice_info = voices[0] + voice_id = voice_info.get("voice_id") or voice_info.get("id") + trial_url = ( + voice_info.get("trial_url") + or voice_info.get("preview_url") + or voice_info.get("voice_url") + ) + if not voice_id: + raise Exception("音色任务成功但未返回 voice_id") + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.ELEMENT_PENDING.value, + voice_id=voice_id, + trial_url=trial_url or "", + ) + changes.append( + StateChange( + job_id=avatar_id, + field_path="message", + value="音色创建成功,准备创建形象主体...", + ) + ) + logger.info(f"Avatar {avatar_id}: voice succeed, voice_id={voice_id}") + + elif kling_status == "failed": + await slots.release(SLOT_KEY, f"avatar:{avatar_id}") + error_msg = result.get("task_msg", "任务执行失败") + _reason = _translate_voice_error(error_msg) + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.VOICE_FAILED.value, + fail_reason=_reason, + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=_reason) + ) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_reason)) + except Exception as e: + logger.exception(f"Avatar {avatar_id}: voice poll error") + if isinstance(e, aiohttp.ClientError | asyncio.TimeoutError) or any( + code in str(e) for code in ["500", "503", "502", "429"] + ): + user_msg, cloud_detail = _translate_system_error(e, "voice_poll") + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.VOICE_FAILED.value, + fail_reason=user_msg, + ) + logger.error(f"Avatar {avatar_id} voice_poll system error: {cloud_detail}") + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=user_msg) + ) + changes.append( + StateChange(job_id=avatar_id, field_path="error", value=user_msg) + ) + else: + _reason = _translate_voice_error(str(e)) + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.VOICE_FAILED.value, + fail_reason=_reason, + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=_reason) + ) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_reason)) + + # ---------- element_pending: 创建主体 ---------- + elif status == AvatarCloneStatus.ELEMENT_PENDING.value: + slot_id = f"avatar:{avatar_id}" + acquired = await slots.acquire(SLOT_KEY, slot_id, MAX_SLOTS) + if not acquired: + return changes + + try: + await _update_avatar_state( + registry, avatar_id, avatar_status=AvatarCloneStatus.ELEMENT_PROCESSING.value + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value="正在创建形象主体...") + ) + element_result = await provider.create_element( + element_name=params.get("name", ""), + element_description=f"{params.get('name', '')} 的克隆形象", + reference_type="video_refer", + element_video_list={ + "refer_videos": [{"video_url": params.get("video_url", "")}] + }, + element_voice_id=_f("voice_id"), + ) + element_task_id = element_result.get("task_id") + if not element_task_id: + raise Exception("未返回主体任务 ID") + await _update_avatar_state( + registry, avatar_id, provider_element_job_id=element_task_id + ) + logger.info(f"Avatar {avatar_id}: created element task {element_task_id}") + except Exception as e: + await slots.release(SLOT_KEY, slot_id) + if isinstance(e, aiohttp.ClientError | asyncio.TimeoutError) or any( + code in str(e) for code in ["500", "503", "502", "429"] + ): + user_msg, cloud_detail = _translate_system_error(e, "element_create") + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.ELEMENT_FAILED.value, + fail_reason=user_msg, + ) + logger.error(f"Avatar {avatar_id} element_create system error: {cloud_detail}") + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=user_msg) + ) + changes.append( + StateChange(job_id=avatar_id, field_path="error", value=user_msg) + ) + else: + _reason = _translate_element_error(str(e)) + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.ELEMENT_FAILED.value, + fail_reason=_reason, + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=_reason) + ) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_reason)) + + # ---------- element_processing: 轮询主体 ---------- + elif status == AvatarCloneStatus.ELEMENT_PROCESSING.value: + provider_element_job_id = _f("provider_element_job_id") + if not provider_element_job_id: + return changes + try: + result = await provider.get_element_task(provider_element_job_id) + kling_status = result.get("task_status", "processing") + logger.info( + f"Avatar {avatar_id}: element task {provider_element_job_id} status={kling_status}" + ) + if kling_status == "processing": + changes.append( + StateChange( + job_id=avatar_id, field_path="message", value="形象主体处理中..." + ) + ) + elif kling_status == "succeed": + await slots.release(SLOT_KEY, f"avatar:{avatar_id}") + task_result = result.get("task_result", {}) + elements = task_result.get("elements", []) + element_id = None + if elements: + element_id = elements[0].get("element_id") + if not element_id: + element_id = task_result.get("element_id") + if not element_id: + raise Exception("主体任务成功但未返回 element_id") + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.SUCCEED.value, + provider_element_id=str(element_id), + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="completed") + ) + changes.append( + StateChange( + job_id=avatar_id, + field_path="result", + value={ + "avatar_id": avatar_id, + "name": params.get("name", ""), + "video_url": params.get("video_url", ""), + "voice_id": _f("voice_id"), + "element_id": int(element_id), + "trial_url": _f("trial_url"), + }, + ) + ) + logger.info(f"Avatar {avatar_id}: element succeed, element_id={element_id}") + + elif kling_status == "failed": + await slots.release(SLOT_KEY, f"avatar:{avatar_id}") + error_msg = result.get("task_msg", "任务执行失败") + _reason = _translate_element_error(error_msg) + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.ELEMENT_FAILED.value, + fail_reason=_reason, + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=_reason) + ) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_reason)) + except Exception as e: + logger.exception(f"Avatar {avatar_id}: element poll error") + if isinstance(e, aiohttp.ClientError | asyncio.TimeoutError) or any( + code in str(e) for code in ["500", "503", "502", "429"] + ): + user_msg, cloud_detail = _translate_system_error(e, "element_poll") + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.ELEMENT_FAILED.value, + fail_reason=user_msg, + ) + logger.error(f"Avatar {avatar_id} element_poll system error: {cloud_detail}") + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=user_msg) + ) + changes.append( + StateChange(job_id=avatar_id, field_path="error", value=user_msg) + ) + else: + _reason = _translate_element_error(str(e)) + await _update_avatar_state( + registry, + avatar_id, + avatar_status=AvatarCloneStatus.ELEMENT_FAILED.value, + fail_reason=_reason, + ) + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=avatar_id, field_path="message", value=_reason) + ) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_reason)) + + # ---------- 已结束状态:移出 running ---------- + elif status in ( + AvatarCloneStatus.SUCCEED.value, + AvatarCloneStatus.VOICE_FAILED.value, + AvatarCloneStatus.ELEMENT_FAILED.value, + ): + await slots.release(SLOT_KEY, f"avatar:{avatar_id}") + if status == AvatarCloneStatus.SUCCEED.value: + changes.append( + StateChange(job_id=avatar_id, field_path="status", value="completed") + ) + else: + _msg = "任务状态异常" + changes.append(StateChange(job_id=avatar_id, field_path="status", value="failed")) + changes.append(StateChange(job_id=avatar_id, field_path="message", value=_msg)) + changes.append(StateChange(job_id=avatar_id, field_path="error", value=_msg)) + + return changes diff --git a/python-api/app/scheduler/handlers/base.py b/python-api/app/scheduler/handlers/base.py new file mode 100644 index 0000000..8529512 --- /dev/null +++ b/python-api/app/scheduler/handlers/base.py @@ -0,0 +1,38 @@ +""" +AsyncHandler 抽象基类 +""" + +from abc import ABC, abstractmethod +from typing import Any + +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager + + +class AsyncHandler(ABC): + """第三方异步任务处理器基类""" + + name: str + slot_key: str + max_slots: int + + @abstractmethod + async def tick( + self, + jobs: list[Any], + registry: JobRegistry, + slots: SlotManager, + ) -> list[StateChange]: + """ + 每个 Tick 执行一次。 + + Args: + jobs: 当前 running 状态的作业记录列表 + registry: 作业注册表(用于读写 Redis 状态) + slots: 全局槽位管理器 + + Returns: + 状态变更列表 + """ + pass diff --git a/python-api/app/scheduler/handlers/copy_handler.py b/python-api/app/scheduler/handlers/copy_handler.py new file mode 100644 index 0000000..20971d2 --- /dev/null +++ b/python-api/app/scheduler/handlers/copy_handler.py @@ -0,0 +1,120 @@ +""" +Copy 任务处理器 +============== + +管理 AnyToCopy 文案提取的提交与轮询。 +""" + +import logging +from typing import Any + +from app.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager +from app.services.anytocopy_service import get_anytocopy_service + +logger = logging.getLogger(__name__) + +SLOT_KEY = "anytocopy:slots" +MAX_SLOTS = 5 + + +class CopyHandler(AsyncHandler): + name = "copy" + slot_key = SLOT_KEY + max_slots = MAX_SLOTS + + async def tick( + self, jobs: list[Any], registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + + for job in jobs: + params = job.params or {} + anytocopy_task_id = params.get("anytocopy_task_id") + video_url = params.get("url", params.get("video_url", "")) + + if anytocopy_task_id: + try: + service = get_anytocopy_service() + result = await service.query_task(anytocopy_task_id) + if result.get("code") != 200: + continue + data = result.get("data", {}) + status = data.get("status") + + if status == "SUCCESS": + result_data = { + "video_url": video_url, + "title": data.get("title", ""), + "content": data.get("content", ""), + "text_content": data.get("textContent", ""), + "platform": data.get("platform", ""), + "duration": data.get("duration", 0), + } + await slots.release(SLOT_KEY, job.job_id) + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="completed") + ) + changes.append( + StateChange( + job_id=job.job_id, field_path="message", value="文案提取完成" + ) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="completed", value=1) + ) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=1)) + changes.append( + StateChange(job_id=job.job_id, field_path="result", value=result_data) + ) + elif status in ("FAILED", "FAILURE"): + await slots.release(SLOT_KEY, job.job_id) + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="failed") + ) + changes.append( + StateChange( + job_id=job.job_id, + field_path="message", + value=f"提取失败: {data.get('errorMessage', '未知错误')}", + ) + ) + changes.append( + StateChange( + job_id=job.job_id, + field_path="error", + value=data.get("errorMessage", ""), + ) + ) + except Exception as e: + logger.error(f"[Copy {job.job_id}] poll error: {e}") + continue + + acquired = await slots.acquire(SLOT_KEY, job.job_id, MAX_SLOTS) + if not acquired: + continue + + try: + service = get_anytocopy_service() + submit_result = await service.submit_task(video_url) + if submit_result.get("code") != 200: + raise Exception(f"提交失败: {submit_result.get('msg')}") + anytocopy_task_id = submit_result["data"] + params["anytocopy_task_id"] = anytocopy_task_id + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value="文案提取任务已提交") + ) + except Exception as e: + await slots.release(SLOT_KEY, job.job_id) + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value=str(e)[:200]) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="error", value=str(e)[:500]) + ) + + return changes diff --git a/python-api/app/scheduler/handlers/image_handler.py b/python-api/app/scheduler/handlers/image_handler.py new file mode 100644 index 0000000..91d5c39 --- /dev/null +++ b/python-api/app/scheduler/handlers/image_handler.py @@ -0,0 +1,190 @@ +""" +Image 任务处理器 +=============== + +管理 Kling 图片生成的提交、轮询、下载。 +不占用 Kling Video/Avatar 槽位,使用独立的图片槽位池。 +""" + +import logging +from pathlib import Path +from typing import Any + +import aiohttp + +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.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager + +logger = logging.getLogger(__name__) + +SLOT_KEY = "kling:image_slots" +MAX_SLOTS = 9 + + +class ImageHandler(AsyncHandler): + name = "image" + slot_key = SLOT_KEY + max_slots = MAX_SLOTS + + async def _get_provider(self) -> KlingAIProvider: + settings = get_settings() + config_loader = get_config_loader() + platform = config_loader.get_platform("klingai") + return KlingAIProvider( + { + "access_key": settings.KLINGAI_ACCESS_KEY or "", + "secret_key": settings.KLINGAI_SECRET_KEY or "", + "base_url": platform.base_url if platform else "https://api-beijing.klingai.com", + } + ) + + async def tick( + self, jobs: list[Any], registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + provider = await self._get_provider() + + for job in jobs: + params = job.params or {} + provider_task_id = params.get("provider_task_id") + project_id = params.get("project_id", "") + prompt = params.get("prompt", "") + image_type = params.get("image_type", "cover") + + if provider_task_id: + # 轮询状态 + try: + result = await provider.get_image_task(provider_task_id) + status = result.get("task_status", "unknown") + except Exception as e: + logger.error(f"[Image {job.job_id}] poll error: {e}") + continue + + if status in ("processing", "submitted"): + continue + + if status == "failed": + await slots.release(SLOT_KEY, job.job_id) + error_msg = result.get("task_status_msg", "图片生成失败") + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="failed") + ) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value=error_msg) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="error", value=error_msg) + ) + continue + + # succeed + images = result.get("task_result", {}).get("images", []) + if not images: + await slots.release(SLOT_KEY, job.job_id) + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="failed") + ) + changes.append( + StateChange( + job_id=job.job_id, + field_path="message", + value="图片生成成功但未返回图片", + ) + ) + continue + + image_url = images[0].get("url") + image_dir = ( + Path.home() / "Documents" / "Meijiaka" / "projects" / project_id / "images" + ) + image_dir.mkdir(parents=True, exist_ok=True) + ext = ".jpg" if ".jpg" in image_url else ".png" + local_path = image_dir / f"{image_type}_{job.job_id[:8]}{ext}" + + try: + async with aiohttp.ClientSession() as session, session.get(image_url) as resp: + resp.raise_for_status() + local_path.write_bytes(await resp.read()) + except Exception as e: + await slots.release(SLOT_KEY, job.job_id) + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="failed") + ) + changes.append( + StateChange( + job_id=job.job_id, field_path="message", value=f"图片下载失败: {e}" + ) + ) + continue + + await slots.release(SLOT_KEY, job.job_id) + result_data = { + "project_id": project_id, + "image_type": image_type, + "local_path": str(local_path), + "prompt": prompt, + } + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="completed") + ) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value="图片生成完成") + ) + changes.append(StateChange(job_id=job.job_id, field_path="completed", value=1)) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=1)) + changes.append( + StateChange(job_id=job.job_id, field_path="result", value=result_data) + ) + continue + + # 提交新任务 + acquired = await slots.acquire(SLOT_KEY, job.job_id, MAX_SLOTS) + if not acquired: + continue + + try: + reference_image = params.get("reference_image") + human_id = params.get("human_id") + if reference_image: + result = await provider.generate_image( + prompt=prompt, + image_url=reference_image, + model="kling-v3", + ) + elif human_id: + result = await provider.generate_image( + prompt=prompt, + model="kling-v3", + aspect_ratio="9:16", + ) + else: + result = await provider.generate_image( + prompt=prompt, + model="kling-v3", + aspect_ratio="9:16", + ) + + provider_task_id = result.get("task_id") + if not provider_task_id: + raise ValueError("未返回任务ID") + params["provider_task_id"] = provider_task_id + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value="图片任务已提交") + ) + except Exception as e: + await slots.release(SLOT_KEY, job.job_id) + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value=str(e)[:200]) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="error", value=str(e)[:500]) + ) + + return changes diff --git a/python-api/app/scheduler/handlers/script_handler.py b/python-api/app/scheduler/handlers/script_handler.py new file mode 100644 index 0000000..4e55e31 --- /dev/null +++ b/python-api/app/scheduler/handlers/script_handler.py @@ -0,0 +1,141 @@ +""" +Script 任务处理器 +================ + +管理脚本生成的执行。 +不占用 Kling/Volc 槽位,使用独立的 script 槽位池。 +""" + +import logging +from typing import Any + +from app.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager +from app.services.anytocopy_service import get_anytocopy_service +from app.services.script_service import ScriptService + +logger = logging.getLogger(__name__) + +SLOT_KEY = "script:slots" +MAX_SLOTS = 10 + + +class ScriptHandler(AsyncHandler): + name = "script" + slot_key = SLOT_KEY + max_slots = MAX_SLOTS + + async def tick( + self, jobs: list[Any], registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + + for job in jobs: + acquired = await slots.acquire(SLOT_KEY, job.job_id, MAX_SLOTS) + if not acquired: + continue + + try: + changes.extend(await self._process_job(job, registry, slots)) + except Exception as e: + logger.exception(f"[Script {job.job_id}] failed") + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append( + StateChange(job_id=job.job_id, field_path="error", value=str(e)[:500]) + ) + finally: + await slots.release(SLOT_KEY, job.job_id) + + return changes + + async def _process_job( + self, job: Any, registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + params = job.params or {} + topic = params.get("topic", "") + style = params.get("style", "default") + duration = params.get("duration", 60) + + await registry.update( + job.job_id, + status="running", + progress=10, + message="分析需求中...", + completed=0, + total=1, + ) + + try: + await __import__("asyncio").sleep(2) + anytocopy = get_anytocopy_service() + extract_result = await anytocopy.extract_text_from_input(topic) + extracted_info = None + actual_topic = topic + is_video_url = extract_result.get("is_video_url", False) + + if is_video_url: + await registry.update( + job.job_id, + progress=30, + message="提取视频素材中...", + ) + video_info = extract_result.get("video_info") + if video_info: + extracted_info = { + "title": video_info.title, + "content": video_info.content, + "text_content": video_info.text_content, + "platform": video_info.platform, + "duration": video_info.duration, + "original_url": topic, + } + actual_topic = extract_result.get("extracted_text") or topic + await registry.update( + job.job_id, + progress=60, + message="生成脚本中...", + ) + else: + await registry.update( + job.job_id, + progress=40, + message="构思脚本中...", + ) + + service = ScriptService() + shots = await service.generate_script( + topic=actual_topic, script_type=style, duration=duration + ) + + # 计算分镜真实总时长 + total_duration = sum(s.duration for s in shots if s.duration) + result_data = { + "title": actual_topic[:50], + "scenes": [s.model_dump() for s in shots], + "total_duration": total_duration, + "style": style, + "shot_count": len(shots), + "extracted_info": extracted_info, + } + + changes.append(StateChange(job_id=job.job_id, field_path="status", value="completed")) + changes.append(StateChange(job_id=job.job_id, field_path="progress", value=100)) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value="脚本生成完成") + ) + changes.append(StateChange(job_id=job.job_id, field_path="completed", value=1)) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=1)) + changes.append(StateChange(job_id=job.job_id, field_path="result", value=result_data)) + + except Exception as exc: + logger.exception(f"[ScriptTask {job.job_id}] Failed") + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value=str(exc)[:200]) + ) + changes.append(StateChange(job_id=job.job_id, field_path="error", value=str(exc)[:500])) + + return changes diff --git a/python-api/app/scheduler/handlers/subtitle_handler.py b/python-api/app/scheduler/handlers/subtitle_handler.py new file mode 100644 index 0000000..204208d --- /dev/null +++ b/python-api/app/scheduler/handlers/subtitle_handler.py @@ -0,0 +1,141 @@ +""" +Subtitle 任务处理器 +================== + +管理火山引擎字幕生成与自动打轴的提交与轮询。 +支持两种模式: +- caption: 字幕识别(从音频/视频提取带时间轴的字幕) +- auto_align: 自动打轴(为已有字幕文本配上时间轴) +""" + +import logging +from typing import Any + +from app.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager +from app.services.volcengine_caption_service import VolcengineCaptionService + +logger = logging.getLogger(__name__) + +SLOT_KEY = "volc:subtitle_slots" +MAX_SLOTS = 5 + + +class SubtitleHandler(AsyncHandler): + name = "subtitle" + slot_key = SLOT_KEY + max_slots = MAX_SLOTS + + async def tick( + self, jobs: list[Any], registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + + for job in jobs: + params = job.params or {} + mode = params.get("mode", "caption") + volc_task_id = params.get("volc_task_id") + project_id = params.get("project_id", "") + video_path = params.get("video", params.get("video_path", "")) + language = params.get("language", "zh") + audio_text = params.get("audio_text", "") + + if volc_task_id: + # 轮询 + try: + service = VolcengineCaptionService() + if mode == "auto_align": + result = await service.query_auto_align_task(volc_task_id, blocking=False) + else: + result = await service.query_caption_task(volc_task_id, blocking=False) + + if result.code == 0: + utterances = result.utterances or [] + result_data = { + "project_id": project_id, + "video_path": video_path, + "language": language, + "mode": mode, + "duration": result.duration, + "utterances": [ + { + "text": u.text, + "start_time": u.start_time, + "end_time": u.end_time, + } + for u in utterances + ], + } + await slots.release(SLOT_KEY, job.job_id) + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="completed") + ) + changes.append( + StateChange( + job_id=job.job_id, field_path="message", value="字幕生成完成" + ) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="completed", value=1) + ) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=1)) + changes.append( + StateChange(job_id=job.job_id, field_path="result", value=result_data) + ) + elif result.code != 2000: + await slots.release(SLOT_KEY, job.job_id) + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="failed") + ) + changes.append( + StateChange( + job_id=job.job_id, + field_path="message", + value=f"字幕识别失败: {result.message}", + ) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="error", value=result.message) + ) + except Exception as e: + logger.error(f"[Subtitle {job.job_id}] poll error: {e}") + continue + + # 提交 + acquired = await slots.acquire(SLOT_KEY, job.job_id, MAX_SLOTS) + if not acquired: + continue + + try: + service = VolcengineCaptionService() + if mode == "auto_align": + if not audio_text: + raise ValueError("auto_align 模式需要提供 audio_text") + volc_task_id = await service.submit_auto_align_task( + audio_url=video_path, + audio_text=audio_text, + ) + else: + volc_task_id = await service.submit_caption_task( + audio_url=video_path, language=language + ) + if not volc_task_id: + raise ValueError("未返回任务ID") + params["volc_task_id"] = volc_task_id + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value="字幕任务已提交") + ) + except Exception as e: + await slots.release(SLOT_KEY, job.job_id) + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append( + StateChange(job_id=job.job_id, field_path="message", value=str(e)[:200]) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="error", value=str(e)[:500]) + ) + + return changes diff --git a/python-api/app/scheduler/handlers/video_handler.py b/python-api/app/scheduler/handlers/video_handler.py new file mode 100644 index 0000000..f3143bc --- /dev/null +++ b/python-api/app/scheduler/handlers/video_handler.py @@ -0,0 +1,425 @@ +""" +Video 任务处理器 +=============== + +管理 Kling 视频生成(含 segment 和 empty_shot)的提交、轮询、下载。 +占用全局槽位:18 +""" + +import asyncio +import contextlib +import json +import logging +import tempfile +import uuid +from pathlib import Path +from typing import Any + +import aiohttp + +from app.ai.providers.klingai_provider import KlingAIProvider, KlingPromptBuilder +from app.config import get_settings +from app.core.config_loader import get_config_loader +from app.scheduler.handlers.base import AsyncHandler +from app.scheduler.models import StateChange +from app.scheduler.registry import JobRegistry +from app.scheduler.slot_manager import SlotManager +from app.services.qiniu_service import get_qiniu_service + +logger = logging.getLogger(__name__) + +SLOT_KEY = "kling:video_slots" +MAX_SLOTS = 18 + + +class VideoHandler(AsyncHandler): + name = "video" + slot_key = SLOT_KEY + max_slots = MAX_SLOTS + + def __init__(self): + self._provider: KlingAIProvider | None = None + + async def _get_provider(self) -> KlingAIProvider: + if self._provider is None: + settings = get_settings() + config_loader = get_config_loader() + platform = config_loader.get_platform("klingai") + self._provider = KlingAIProvider( + { + "access_key": settings.KLINGAI_ACCESS_KEY or "", + "secret_key": settings.KLINGAI_SECRET_KEY or "", + "base_url": ( + platform.base_url if platform else "https://api-beijing.klingai.com" + ), + } + ) + return self._provider + + def _get_project_video_dir(self, project_id: str) -> Path: + video_dir = Path.home() / "Documents" / "Meijiaka" / "projects" / project_id / "videos" + video_dir.mkdir(parents=True, exist_ok=True) + return video_dir + + async def _download_video(self, video_url: str, local_path: Path) -> None: + async with aiohttp.ClientSession() as session, session.get(video_url) as resp: + resp.raise_for_status() + local_path.write_bytes(await resp.read()) + + async def _download_image(self, image_url: str, local_path: Path) -> None: + async with aiohttp.ClientSession() as session, session.get(image_url) as resp: + resp.raise_for_status() + local_path.write_bytes(await resp.read()) + + async def _poll_image_task(self, provider: KlingAIProvider, image_task_id: str) -> str: + """轮询文生图任务,返回图片 URL""" + timeout = 600 + start = asyncio.get_event_loop().time() + while True: + if asyncio.get_event_loop().time() - start > timeout: + raise TimeoutError("文生图轮询超时") + result = await provider.get_image_task(image_task_id) + status = result.get("task_status", "unknown") + if status == "succeed": + images = result.get("task_result", {}).get("images", []) + if images and images[0].get("url"): + return images[0]["url"] + raise Exception("文生图成功但未返回图片 URL") + if status == "failed": + raise Exception(result.get("task_status_msg", "文生图失败")) + await asyncio.sleep(5) + + async def tick( + self, jobs: list[Any], registry: JobRegistry, slots: SlotManager + ) -> list[StateChange]: + changes: list[StateChange] = [] + provider = await self._get_provider() + + for job in jobs: + job_changes = await self._process_job(job, registry, slots, provider) + changes.extend(job_changes) + + return changes + + async def _process_job( + self, job: Any, registry: JobRegistry, slots: SlotManager, provider: KlingAIProvider + ) -> list[StateChange]: + changes: list[StateChange] = [] + params = job.params or {} + shots = params.get("shots", []) + if isinstance(shots, str): + shots = json.loads(shots) + params["shots"] = shots + if not shots: + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append(StateChange(job_id=job.job_id, field_path="error", value="没有镜头数据")) + return changes + + project_id = params.get("project_id", job.job_id) + + # 1. 查询 submitted 状态的 shots + for i, shot in enumerate(shots): + if shot.get("status") != "submitted": + continue + provider_task_id = shot.get("provider_task_id") + if not provider_task_id: + continue + + try: + if shot.get("type") == "segment": + result = await provider.get_omni_video_task(provider_task_id) + else: + result = await provider.get_video_task( + provider_task_id, task_type="image2video" + ) + status = result.get("task_status", "unknown") + except Exception as e: + logger.error(f"[Video {job.job_id}] Query shot {shot['id']} error: {e}") + # 累计查询失败计数 + fail_count = shot.get("query_fail_count", 0) + 1 + if fail_count >= 5: + await slots.release(SLOT_KEY, f"{job.job_id}:{shot['id']}") + shots[i]["status"] = "failed" + shots[i]["error_message"] = f"查询状态连续失败: {e}"[:500] + shots[i]["query_fail_count"] = fail_count + changes.append( + StateChange(job_id=job.job_id, field_path="params", value=params) + ) + else: + shots[i]["query_fail_count"] = fail_count + changes.append( + StateChange(job_id=job.job_id, field_path="params", value=params) + ) + continue + + # 检查超时:超过 2 小时还在 processing 就标记失败 + created_at = result.get("created_at", 0) # KlingAI 返回的是 Unix 毫秒时间戳 + import time + now_ms = int(time.time() * 1000) + if status == "processing" and created_at > 0 and (now_ms - created_at) > 2 * 60 * 60 * 1000: + # 超时 2 小时,标记失败释放槽位 + await slots.release(SLOT_KEY, f"{job.job_id}:{shot['id']}") + shots[i]["status"] = "failed" + shots[i]["error_message"] = "生成超时(超过 2 小时仍在处理中)" + logger.warning(f"[Video {job.job_id}] Shot {shot['id']} timeout, marked as failed") + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + + elif status == "succeed": + await slots.release(SLOT_KEY, f"{job.job_id}:{shot['id']}") + videos = result.get("task_result", {}).get("videos", []) + video_url = videos[0].get("url") if videos else None + if video_url: + shots[i]["video_url"] = video_url + shots[i]["status"] = "completed" + # 完成就立即下载,不用等全部完成 + logger.info(f"[Video {job.job_id}] Shot {shot['id']} completed, downloading...") + await self._download_and_upload(project_id, shots[i]) + else: + shots[i]["status"] = "failed" + shots[i]["error_message"] = "任务成功但未返回视频" + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + + elif status == "failed": + await slots.release(SLOT_KEY, f"{job.job_id}:{shot['id']}") + shots[i]["status"] = "failed" + shots[i]["error_message"] = result.get("task_status_msg", "生成失败")[:500] + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + + # 2. 提交 pending 状态的 shots(填槽),segment 优先于 empty_shot + pending_shots = sorted( + [s for s in shots if s.get("status") == "pending"], + key=lambda s: 0 if s.get("type") == "segment" else 1, + ) + for shot in pending_shots: + slot_id = f"{job.job_id}:{shot['id']}" + acquired = await slots.acquire(SLOT_KEY, slot_id, MAX_SLOTS) + if not acquired: + continue # 当前这个获取失败(槽位满或网络问题),跳过尝试下一个,下次 tick 再重试 + + try: + if shot.get("type") == "segment": + human_id = shot.get("human_id") or params.get("human_id") + if not human_id: + raise ValueError(f"分镜 {shot['id']} 缺少 human_id") + prompt = KlingPromptBuilder.omni_segment( + shot.get("scene", ""), shot.get("voiceover", "") + ) + result = await provider.generate_video_omni( + prompt=prompt, + model="kling-v3-omni", + mode="pro", + aspect_ratio="9:16", + duration=shot.get("duration"), + sound="on", + multi_shot=False, + element_list=[{"element_id": str(human_id)}], + ) + else: + # empty_shot: 文生图 -> 上传七牛 -> 图生视频 + result = await self._submit_empty_shot(shot, provider) + + provider_task_id = result.get("task_id") + if not provider_task_id: + raise ValueError(f"创建任务失败,未返回 provider_task_id: {result}") + + shot["provider_task_id"] = provider_task_id + shot["status"] = "submitted" + logger.info(f"[Video {job.job_id}] Shot {shot['id']} submitted: {provider_task_id}") + except Exception as e: + await slots.release(SLOT_KEY, slot_id) + shot["status"] = "failed" + shot["error_message"] = str(e)[:500] + logger.error(f"[Video {job.job_id}] Submit shot {shot['id']} failed: {e}") + + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + + # 3. 检查是否所有 shots 都完成,做最终汇总 + all_done = all(s.get("status") in ("completed", "failed") for s in shots) + completed = sum(1 for s in shots if s.get("status") == "completed") + failed = sum(1 for s in shots if s.get("status") == "failed") + + if all_done: + # 下载已经在每个分镜完成时处理过了,这里只重试下载失败的 + retry_download_tasks = [ + self._download_and_upload(project_id, shot) + for shot in shots + if shot.get("status") == "completed" + and shot.get("video_url") + and not shot.get("local_path") + ] + if retry_download_tasks: + logger.info(f"[Video {job.job_id}] Final retry downloading {len(retry_download_tasks)} videos...") + await asyncio.gather(*retry_download_tasks, return_exceptions=True) + logger.info(f"[Video {job.job_id}] Retry downloads finished") + # shots 字典已被 _download_and_upload 更新,写回 params + changes.append(StateChange(job_id=job.job_id, field_path="params", value=params)) + + # 下载后重新统计,以反映可能的下载失败 + completed = sum(1 for s in shots if s.get("status") == "completed") + failed = sum(1 for s in shots if s.get("status") == "failed") + + if completed == 0 and failed > 0: + errors = "; ".join( + f"{s.get('id')}: {s.get('error_message')}" + for s in shots + if s.get("error_message") + ) + changes.append(StateChange(job_id=job.job_id, field_path="status", value="failed")) + changes.append( + StateChange( + job_id=job.job_id, + field_path="message", + value=f"全部失败 ({failed}/{len(shots)})", + ) + ) + changes.append(StateChange(job_id=job.job_id, field_path="error", value=errors)) + changes.append( + StateChange(job_id=job.job_id, field_path="completed", value=len(shots)) + ) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=len(shots))) + changes.append(StateChange(job_id=job.job_id, field_path="progress", value=100)) + else: + changes.append( + StateChange(job_id=job.job_id, field_path="status", value="completed") + ) + changes.append( + StateChange( + job_id=job.job_id, + field_path="message", + value=f"完成!成功 {completed},失败 {failed}", + ) + ) + changes.append( + StateChange(job_id=job.job_id, field_path="completed", value=len(shots)) + ) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=len(shots))) + changes.append(StateChange(job_id=job.job_id, field_path="progress", value=100)) + # result 字段包含 shots 汇总(含下载后的 local_path / qiniu_url) + result_data = { + "project_id": project_id, + "completed": completed, + "failed": failed, + "total": len(shots), + "shots": [ + { + "shot_id": s.get("id"), + "type": s.get("type"), + "status": s.get("status"), + "task_id": s.get("provider_task_id"), + "video_url": s.get("video_url"), + "local_path": s.get("local_path"), + "qiniu_url": s.get("qiniu_url"), + "error_message": s.get("error_message"), + } + for s in shots + ], + } + changes.append( + StateChange(job_id=job.job_id, field_path="result", value=result_data) + ) + else: + done_count = completed + failed + changes.append(StateChange(job_id=job.job_id, field_path="status", value="running")) + changes.append( + StateChange( + job_id=job.job_id, + field_path="message", + value=f"{done_count}/{len(shots)} 个镜头处理中", + ) + ) + changes.append(StateChange(job_id=job.job_id, field_path="completed", value=done_count)) + changes.append(StateChange(job_id=job.job_id, field_path="total", value=len(shots))) + + return changes + + async def _submit_empty_shot( + self, shot: dict[str, Any], provider: KlingAIProvider + ) -> dict[str, Any]: + """空镜 shot 的完整提交流程:文生图 -> 上传七牛 -> 图生视频""" + qiniu = get_qiniu_service() + + # 1. 文生图 + image_result = await provider.generate_image( + prompt=shot.get("scene", ""), + model="kling-v3", + aspect_ratio="9:16", + ) + image_task_id = image_result.get("task_id") + if not image_task_id: + raise ValueError(f"文生图创建失败: {image_result}") + + # 2. 轮询图片完成 + image_url = await self._poll_image_task(provider, image_task_id) + + # 3. 下载图片 + temp_dir = Path(tempfile.gettempdir()) / "meijiaka_empty_shot" + temp_dir.mkdir(parents=True, exist_ok=True) + temp_image_path = temp_dir / f"{image_task_id}.jpg" + await self._download_image(image_url, temp_image_path) + + # 4. 上传七牛 + qiniu_result = qiniu.upload_file( + local_path=str(temp_image_path), + file_type="image", + check_duplicate=True, + ) + qiniu_image_url = qiniu_result["url"] + with contextlib.suppress(Exception): + temp_image_path.unlink() + + # 5. 图生视频 + voice_id = shot.get("voice_id") or get_settings().DEFAULT_EMPTY_SHOT_VOICE_ID + prompt = KlingPromptBuilder.empty_shot(shot.get("scene", ""), shot.get("voiceover", "")) + result = await provider.generate_video_image2video( + prompt=prompt, + image_url=qiniu_image_url, + model="kling-v2-6", + mode="pro", + duration=shot.get("duration"), + voice_list=[{"voice_id": voice_id}], + sound="on", + negative_prompt="画外音没有标点的时候不要轻易断句", + ) + return result + + async def _download_and_upload(self, project_id: str, shot: dict[str, Any]) -> None: + """下载视频到本地并上传七牛。直接更新传入的 shot 字典,不操作 Redis。""" + video_url = shot.get("video_url") + if not video_url: + shot["status"] = "failed" + shot["error_message"] = "没有视频URL" + return + + video_dir = self._get_project_video_dir(project_id) + + # 清理同 shot_id 的旧视频文件(避免重新生成后前端缓存不刷新) + import glob as stdlib_glob + + pattern = f"scene_{stdlib_glob.escape(str(shot['id']))}_*.mp4" + for old_file in video_dir.glob(pattern): + try: + old_file.unlink() + logger.info(f"[Video] Removed old file: {old_file}") + except Exception as e: + logger.warning(f"[Video] Failed to remove old file {old_file}: {e}") + + # 使用随机后缀命名,确保前端检测到 filePath 变化并重新加载 + local_path = video_dir / f"scene_{shot['id']}_{uuid.uuid4().hex[:6]}.mp4" + + try: + await self._download_video(video_url, local_path) + shot["local_path"] = str(local_path) + + try: + qiniu = get_qiniu_service() + qiniu_result = qiniu.upload_video(local_path=str(local_path)) + shot["qiniu_url"] = qiniu_result["url"] + except Exception as e: + logger.warning(f"[Video] Shot {shot['id']} upload qiniu failed: {e}") + shot["qiniu_url"] = None + + logger.info(f"[Video] Shot {shot['id']} download/upload done: {local_path}") + except Exception as e: + logger.error(f"[Video] Shot {shot['id']} download failed: {e}") + shot["status"] = "failed" + shot["error_message"] = f"下载失败: {e}"[:500] diff --git a/python-api/app/scheduler/main.py b/python-api/app/scheduler/main.py new file mode 100644 index 0000000..5f4bdba --- /dev/null +++ b/python-api/app/scheduler/main.py @@ -0,0 +1,48 @@ +""" +Async Engine 独立进程入口 +========================= + +usage: python -m app.scheduler.main +""" + +import asyncio +import logging +import sys + +from app.scheduler.engine import AsyncEngine +from app.scheduler.handlers.avatar_handler import AvatarHandler +from app.scheduler.handlers.copy_handler import CopyHandler +from app.scheduler.handlers.image_handler import ImageHandler +from app.scheduler.handlers.script_handler import ScriptHandler +from app.scheduler.handlers.subtitle_handler import SubtitleHandler +from app.scheduler.handlers.video_handler import VideoHandler + +logger = logging.getLogger("scheduler") + + +def setup_logging() -> None: + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + logging.basicConfig( + level=logging.INFO, + format=log_format, + handlers=[logging.StreamHandler(sys.stdout)], + ) + + +async def main() -> None: + setup_logging() + engine = AsyncEngine() + engine.register(VideoHandler()) + engine.register(AvatarHandler()) + engine.register(ImageHandler()) + engine.register(SubtitleHandler()) + engine.register(CopyHandler()) + engine.register(ScriptHandler()) + await engine.run_forever(interval=10.0, min_interval=2.0) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Scheduler stopped by user") diff --git a/python-api/app/scheduler/models.py b/python-api/app/scheduler/models.py new file mode 100644 index 0000000..36d7200 --- /dev/null +++ b/python-api/app/scheduler/models.py @@ -0,0 +1,47 @@ +""" +Scheduler 内部数据模型 +""" + +import json +from dataclasses import dataclass, field +from typing import Any + +from app.schemas.enums import JobStatus + + +@dataclass +class JobRecord: + """调度器内部使用的作业记录""" + + job_id: str + job_type: str + user_id: str + project_id: str = "" + status: JobStatus = field(default=JobStatus.PENDING) + progress: int = 0 + message: str = "等待执行..." + completed: int = 0 + total: int = 0 + result: dict[str, Any] = field(default_factory=dict) + error: str | None = None + params: dict[str, Any] | Any = field(default_factory=dict) + created_at: str = "" + + +@dataclass(frozen=True) +class StateChange: + """状态变更记录,供 Registry 批量写入""" + + job_id: str + field_path: str # 如 "shots.0.status" 或 "status" + value: Any + + def to_redis_command(self) -> tuple[str, str, str]: + key = f"job:{self.job_id}" + if isinstance(self.value, dict | list): + value = json.dumps(self.value, ensure_ascii=False) + elif self.value is None: + value = "" + else: + value = str(self.value) + return key, self.field_path, value diff --git a/python-api/app/scheduler/registry.py b/python-api/app/scheduler/registry.py new file mode 100644 index 0000000..69a9d7f --- /dev/null +++ b/python-api/app/scheduler/registry.py @@ -0,0 +1,143 @@ +""" +作业注册表 - Redis 运行时状态读写 +================================== + +所有 running 作业的状态统一存储在 Redis 中,供 Scheduler Tick 读取、更新。 +""" + +import json +import logging +from datetime import UTC +from typing import Any + +from redis.asyncio import Redis + +from app.scheduler.models import JobRecord + +logger = logging.getLogger(__name__) + +KEY_RUNNING_SET = "scheduler:running_tasks" + + +def _job_key(job_id: str) -> str: + return f"job:{job_id}" + + +class JobRegistry: + """基于 Redis 的作业注册表""" + + def __init__(self, redis: Redis): + self.redis = redis + + async def create( + self, + job_id: str, + job_type: str, + user_id: str, + status: str = "pending", + params: dict[str, Any] | None = None, + ttl: int = 86400, + ) -> None: + """创建新的作业记录""" + from datetime import datetime + + data = { + "type": job_type, + "user_id": user_id, + "status": status, + "progress": "0", + "message": "等待执行...", + "completed": "0", + "total": "0", + "created_at": datetime.now(UTC).isoformat(), + } + if params: + data["params"] = json.dumps(params, ensure_ascii=False) + + await self.redis.hset(_job_key(job_id), mapping=data) + await self.redis.expire(_job_key(job_id), ttl) + logger.debug(f"Registry created: {job_id}, type={job_type}") + + async def update(self, job_id: str, **fields: Any) -> None: + """更新作业字段""" + mapping: dict[str, str] = {} + for key, value in fields.items(): + if isinstance(value, dict | list): + mapping[key] = json.dumps(value, ensure_ascii=False) + elif value is None: + mapping[key] = "" + else: + mapping[key] = str(value) + await self.redis.hset(_job_key(job_id), mapping=mapping) + logger.debug(f"Registry updated: {job_id}, fields={list(fields.keys())}") + + async def get(self, job_id: str) -> JobRecord | None: + """读取完整作业记录""" + data = await self.redis.hgetall(_job_key(job_id)) + if not data: + return None + + def _parse(key: str, raw: str) -> Any: + if key in ("result", "params") and raw: + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + if key in ("progress", "completed", "total"): + try: + return int(raw) + except ValueError: + return 0 + return raw + + parsed = {k: _parse(k, v) for k, v in data.items()} + job_type = parsed.get("type", "") + params_raw = parsed.get("params", {}) + params = params_raw if isinstance(params_raw, dict) else {} + + return JobRecord( + job_id=job_id, + job_type=job_type, + user_id=parsed.get("user_id", ""), + project_id=str(params.get("project_id", "")), + status=parsed.get("status", "unknown"), + progress=parsed.get("progress", 0), + message=parsed.get("message", ""), + completed=parsed.get("completed", 0), + total=parsed.get("total", 0), + result=parsed.get("result", {}), + error=parsed.get("error"), + params=params, + created_at=parsed.get("created_at", ""), + ) + + async def add_running(self, job_id: str) -> None: + """将作业标记为 running(加入全局 running 集合)""" + await self.redis.sadd(KEY_RUNNING_SET, job_id) + + async def remove_running(self, job_id: str) -> None: + """将作业从全局 running 集合移除""" + await self.redis.srem(KEY_RUNNING_SET, job_id) + + async def get_running_job_ids(self) -> list[str]: + """获取所有 running 的作业 ID 列表""" + members = await self.redis.smembers(KEY_RUNNING_SET) + return list(members) + + async def list_running_by_user(self, user_id: str) -> list[JobRecord]: + """获取指定用户的所有 running 作业""" + job_ids = await self.get_running_job_ids() + if not job_ids: + return [] + + results: list[JobRecord] = [] + for job_id in job_ids: + job = await self.get(job_id) + if job and job.user_id == user_id: + results.append(job) + return results + + async def delete(self, job_id: str) -> None: + """删除作业记录""" + await self.redis.delete(_job_key(job_id)) + await self.redis.srem(KEY_RUNNING_SET, job_id) diff --git a/python-api/app/scheduler/slot_manager.py b/python-api/app/scheduler/slot_manager.py new file mode 100644 index 0000000..a14a534 --- /dev/null +++ b/python-api/app/scheduler/slot_manager.py @@ -0,0 +1,63 @@ +""" +全局并发槽位管理器 +================== + +基于 Redis SET + Lua 脚本实现严格原子的槽位申请与释放。 +""" + +import logging + +from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + +# Lua 脚本:原子执行 SADD -> SCARD -> 条件 SREM +_ACQUIRE_LUA = """ +local key = KEYS[1] +local slot_id = ARGV[1] +local max_slots = tonumber(ARGV[2]) +redis.call('sadd', key, slot_id) +local count = redis.call('scard', key) +if count > max_slots then + redis.call('srem', key, slot_id) + return 0 +end +redis.call('expire', key, 1800) +return 1 +""" + + +class SlotManager: + """全局并发槽位管理器""" + + def __init__(self, redis: Redis): + self.redis = redis + + async def acquire(self, slot_key: str, slot_id: str, max_slots: int) -> bool: + """申请一个槽位。返回 True 表示成功,False 表示槽位已满。""" + try: + result = await self.redis.eval(_ACQUIRE_LUA, 1, slot_key, slot_id, str(max_slots)) + acquired = result == 1 + if acquired: + logger.debug(f"Slot acquired: {slot_key}/{slot_id} (max={max_slots})") + else: + logger.debug(f"Slot full: {slot_key}/{slot_id} (max={max_slots})") + return acquired + except Exception as e: + logger.warning(f"Slot acquire error: {slot_key}/{slot_id}: {e}") + return False + + async def release(self, slot_key: str, slot_id: str) -> None: + """释放一个槽位。""" + try: + await self.redis.srem(slot_key, slot_id) + logger.debug(f"Slot released: {slot_key}/{slot_id}") + except Exception as e: + logger.warning(f"Slot release error: {slot_key}/{slot_id}: {e}") + + async def count(self, slot_key: str) -> int: + """获取当前已占用的槽位数量。""" + try: + return await self.redis.scard(slot_key) + except Exception: + return 0 diff --git a/python-api/app/schemas/__init__.py b/python-api/app/schemas/__init__.py new file mode 100644 index 0000000..abd66d8 --- /dev/null +++ b/python-api/app/schemas/__init__.py @@ -0,0 +1,78 @@ +""" +Schema 导出 +=========== +""" + +from app.schemas.auth import LoginResponse, MobileLoginRequest, TokenPayload, UserInfo +from app.schemas.common import ( + ApiErrorResponse, + ApiResponse, + PaginatedData, + PaginationParams, + error_response, + success_response, +) +from app.schemas.enums import ( + AvatarCloneStatus, + JobStatus, + KlingTaskStatus, + SegmentStatus, +) +from app.schemas.job import ( + AvatarCloneJobParams, + CopyJobParams, + ImageJobParams, + JobParams, + ScriptJobParams, + SubtitleJobParams, + VideoJobParams, +) +from app.schemas.script import ( + GenerateScriptRequest, + ModelHealthInfo, + ModelHealthResponse, + PolishRequest, + ScriptGenerationEvent, + ScriptShot, + TestModelRequest, + TestModelResponse, +) +from app.schemas.segment import Segment + +__all__ = [ + # Common + "ApiResponse", + "ApiErrorResponse", + "PaginatedData", + "PaginationParams", + "success_response", + "error_response", + # Auth + "MobileLoginRequest", + "LoginResponse", + "UserInfo", + "TokenPayload", + # Enums + "JobStatus", + "SegmentStatus", + "AvatarCloneStatus", + "KlingTaskStatus", + # Segment / Job + "Segment", + "VideoJobParams", + "ImageJobParams", + "SubtitleJobParams", + "CopyJobParams", + "AvatarCloneJobParams", + "ScriptJobParams", + "JobParams", + # Script + "GenerateScriptRequest", + "PolishRequest", + "ScriptGenerationEvent", + "ScriptShot", + "ModelHealthInfo", + "ModelHealthResponse", + "TestModelRequest", + "TestModelResponse", +] diff --git a/python-api/app/schemas/auth.py b/python-api/app/schemas/auth.py new file mode 100644 index 0000000..4c17634 --- /dev/null +++ b/python-api/app/schemas/auth.py @@ -0,0 +1,36 @@ +""" +认证相关 Schema +=============== +""" + +from pydantic import BaseModel, Field + + +class MobileLoginRequest(BaseModel): + """手机号登录请求""" + + mobile: str = Field(..., description="手机号", min_length=11, max_length=20) + nickname: str | None = Field(None, description="用户昵称", max_length=64) + + +class UserInfo(BaseModel): + """用户信息""" + + id: str = Field(..., description="用户 ID") + nickname: str = Field(..., description="用户昵称") + avatar: str = Field(default="", description="头像 URL") + + +class LoginResponse(BaseModel): + """登录响应""" + + token: str = Field(..., description="JWT 访问令牌") + user: UserInfo = Field(..., description="用户信息") + + +class TokenPayload(BaseModel): + """Token 载荷""" + + sub: str | None = Field(None, description="用户 ID") + mobile: str | None = Field(None, description="手机号") + exp: int | None = Field(None, description="过期时间戳") diff --git a/python-api/app/schemas/avatar.py b/python-api/app/schemas/avatar.py new file mode 100644 index 0000000..3309ec9 --- /dev/null +++ b/python-api/app/schemas/avatar.py @@ -0,0 +1,33 @@ +""" +Avatar Schema +============= + +形象克隆相关的 Pydantic 模型,用于 CRUD 强类型化。 +""" + +from pydantic import BaseModel, Field + +from app.schemas.enums import AvatarCloneStatus + + +class AvatarCreate(BaseModel): + """创建形象记录""" + + id: str + user_id: str + name: str = Field(..., min_length=1, max_length=64) + video_url: str = Field(..., min_length=1) + status: AvatarCloneStatus = Field(default=AvatarCloneStatus.PENDING) + + +class AvatarUpdate(BaseModel): + """更新形象记录""" + + name: str | None = None + status: AvatarCloneStatus | None = None + provider_voice_job_id: str | None = None + provider_element_job_id: str | None = None + provider_element_id: int | None = None + voice_id: str | None = None + trial_url: str | None = None + fail_reason: str | None = None diff --git a/python-api/app/schemas/caption.py b/python-api/app/schemas/caption.py new file mode 100644 index 0000000..55b8335 --- /dev/null +++ b/python-api/app/schemas/caption.py @@ -0,0 +1,98 @@ +""" +字幕生成 Schema +=============== + +火山引擎音视频字幕服务的请求/响应模型。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class CaptionWord(BaseModel): + """单个字/词的时间轴信息""" + + text: str = Field(description="字/词内容") + start_time: int = Field(description="开始时间(毫秒)") + end_time: int = Field(description="结束时间(毫秒)") + + +class CaptionUtterance(BaseModel): + """一句话/一段字幕的时间轴信息""" + + text: str = Field(description="文本内容") + start_time: int = Field(description="开始时间(毫秒)") + end_time: int = Field(description="结束时间(毫秒)") + words: list[CaptionWord] | None = Field(default_factory=list, description="字词级时间轴") + + +class CaptionTaskResponse(BaseModel): + """字幕任务提交响应""" + + task_id: str = Field(description="任务ID") + status: str = Field(description="任务状态: pending/processing/completed/failed") + + +class CaptionResult(BaseModel): + """字幕生成结果""" + + code: int = Field(description="状态码: 0=成功, 2000=处理中") + message: str = Field(description="状态信息") + duration: float = Field(description="音频时长(秒)") + utterances: list[CaptionUtterance] | None = Field( + default_factory=list, description="字幕时间轴列表" + ) + + +class CaptionSubmitRequest(BaseModel): + """字幕生成任务提交请求""" + + audio_url: str = Field(..., description="音频/视频文件URL") + language: str = Field( + "zh-CN", + description="语言: zh-CN, en-US, ja-JP, ko-KR, es-MX, ru-RU, fr-FR, yue, wuu, nan, ug", + ) + caption_type: str = Field( + "auto", description="识别类型: auto(自动), speech(说话), singing(歌词)" + ) + use_punc: bool = Field(True, description="自动标点: True/False") + use_itn: bool = Field(True, description="数字转换: True(中文数字转阿拉伯数字)") + words_per_line: int = Field(46, ge=1, le=100, description="每行字数") + max_lines: int = Field(1, ge=1, le=5, description="每屏行数") + + +class CaptionQueryRequest(BaseModel): + """字幕任务查询请求""" + + task_id: str = Field(..., description="任务ID") + blocking: bool = Field(True, description="是否阻塞等待结果") + + +class AutoAlignSubmitRequest(BaseModel): + """自动字幕打轴任务提交请求""" + + audio_url: str = Field(..., description="音频/视频文件URL") + audio_text: str = Field(..., description="要打轴的字幕文本") + caption_type: str = Field("speech", description="识别类型: speech(说话), singing(歌词)") + sta_punc_mode: int = Field( + 3, ge=1, le=3, description="标点模式: 1=省略句末, 2=空格代替, 3=保留完整" + ) + + +class AutoAlignResult(BaseModel): + """自动字幕打轴结果""" + + code: int = Field(description="状态码: 0=成功, 2000=处理中") + message: str = Field(description="状态信息") + duration: float = Field(description="音频时长(秒)") + utterances: list[CaptionUtterance] | None = Field( + default_factory=list, description="打轴后的字幕时间轴" + ) + + +class SrtSubtitleResponse(BaseModel): + """SRT 字幕格式响应""" + + srt_content: str = Field(description="SRT 格式字幕内容") + utterances: list[CaptionUtterance] = Field(description="原始时间轴数据") diff --git a/python-api/app/schemas/common.py b/python-api/app/schemas/common.py new file mode 100644 index 0000000..89c581b --- /dev/null +++ b/python-api/app/schemas/common.py @@ -0,0 +1,78 @@ +""" +通用响应格式 Schema +================== + +与前端 ApiResponse 保持一致: +{ code: number; data: T; message: string } +""" + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class ApiResponse(BaseModel, Generic[T]): + """ + 统一 API 响应格式 + + Attributes: + code: HTTP 状态码(200 表示成功) + data: 响应数据(泛型) + message: 提示信息 + """ + + code: int = Field(default=200, description="状态码,200 表示成功") + data: T | None = Field(default=None, description="响应数据") + message: str = Field(default="success", description="提示信息") + + class Config: + json_schema_extra = { + "example": { + "code": 200, + "data": {}, + "message": "success", + } + } + + +class PaginatedData(BaseModel, Generic[T]): + """分页数据包装""" + + items: list[T] = Field(description="数据列表") + total: int = Field(description="总数") + page: int = Field(description="当前页码") + page_size: int = Field(description="每页数量") + has_more: bool = Field(description="是否有更多") + + +class PaginationParams(BaseModel): + """分页请求参数""" + + page: int = Field(default=1, ge=1, description="页码") + page_size: int = Field(default=20, ge=1, le=100, description="每页数量") + + @property + def offset(self) -> int: + return (self.page - 1) * self.page_size + + +class ApiErrorResponse(BaseModel): + """错误响应格式""" + + code: int = Field(description="错误码") + message: str = Field(description="错误信息") + detail: dict[str, Any] | None = Field(default=None, description="详细错误信息") + + +def success_response(data: T | None = None, message: str = "success") -> ApiResponse[T]: + """构造成功响应""" + return ApiResponse(code=200, data=data, message=message) + + +def error_response( + code: int, message: str, detail: dict[str, Any] | None = None +) -> ApiErrorResponse: + """构造错误响应""" + return ApiErrorResponse(code=code, message=message, detail=detail) diff --git a/python-api/app/schemas/enums.py b/python-api/app/schemas/enums.py new file mode 100644 index 0000000..7b333a0 --- /dev/null +++ b/python-api/app/schemas/enums.py @@ -0,0 +1,49 @@ +""" +业务枚举定义 +============ + +所有跨层使用的状态枚举集中定义在此,避免字符串硬编码。 +""" + +from enum import Enum + + +class JobStatus(str, Enum): + """调度器作业状态""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class SegmentStatus(str, Enum): + """视频分镜处理状态""" + + PENDING = "pending" + SUBMITTED = "submitted" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class AvatarCloneStatus(str, Enum): + """形象克隆状态机""" + + PENDING = "pending" + VOICE_PROCESSING = "voice_processing" + VOICE_FAILED = "voice_failed" + ELEMENT_PENDING = "element_pending" + ELEMENT_PROCESSING = "element_processing" + ELEMENT_FAILED = "element_failed" + SUCCEED = "succeed" + TIMEOUT = "timeout" + + +class KlingTaskStatus(str, Enum): + """Kling AI 供应商任务状态(仅限 Provider 层使用)""" + + SUBMITTED = "submitted" + PROCESSING = "processing" + SUCCEED = "succeed" + FAILED = "failed" diff --git a/python-api/app/schemas/job.py b/python-api/app/schemas/job.py new file mode 100644 index 0000000..728c567 --- /dev/null +++ b/python-api/app/schemas/job.py @@ -0,0 +1,73 @@ +""" +调度器作业参数 Schema +===================== + +定义 Scheduler (Layer 4) 使用的强类型参数模型, +取代裸 `dict[str, Any]`,根除类型漂移。 +""" + +from pydantic import BaseModel + +from app.schemas.segment import Segment + + +class VideoJobParams(BaseModel): + """视频生成作业参数""" + + project_id: str + user_id: str + human_id: str | None = None + segments: list[Segment] + + +class ImageJobParams(BaseModel): + """图片生成作业参数""" + + project_id: str + user_id: str + prompt: str + image_type: str = "cover" + reference_image: str | None = None + human_id: str | None = None + + +class SubtitleJobParams(BaseModel): + """字幕生成作业参数""" + + project_id: str + video_path: str + language: str = "zh" + mode: str = "caption" # "caption" | "auto_align" + audio_text: str | None = None # auto_align 模式时需要 + + +class CopyJobParams(BaseModel): + """文案提取作业参数""" + + video_url: str + + +class AvatarCloneJobParams(BaseModel): + """形象克隆作业参数""" + + avatar_id: str + name: str + video_url: str + + +class ScriptJobParams(BaseModel): + """脚本生成作业参数""" + + topic: str + style: str + duration: int + + +JobParams = ( + VideoJobParams + | ImageJobParams + | SubtitleJobParams + | CopyJobParams + | AvatarCloneJobParams + | ScriptJobParams +) diff --git a/python-api/app/schemas/script.py b/python-api/app/schemas/script.py new file mode 100644 index 0000000..1c198a8 --- /dev/null +++ b/python-api/app/schemas/script.py @@ -0,0 +1,98 @@ +""" +脚本相关 Schema +=============== +""" + +from typing import Any + +from pydantic import BaseModel, Field + +from app.schemas.segment import Segment + +ScriptShot = Segment + + +class GenerateScriptRequest(BaseModel): + """生成脚本请求""" + + topic: str = Field(..., description="创作主题/灵感", min_length=1, max_length=1000) + duration: int = Field(default=45, ge=30, le=180, description="视频时长(秒)") + script_type: str = Field(default="干货型", description="脚本类型") + model: str | None = Field(None, description="指定模型(可选)") + + +class PolishRequest(BaseModel): + """润色请求""" + + content: str = Field(..., description="待润色内容", min_length=1) + polish_type: str = Field(default="voiceover", description="润色类型:scene / voiceover") + shot_type: str | None = Field( + default="segment", + description="镜头类型:segment(分镜) / empty_shot(空镜),用于画面润色时区分", + ) + + +class ScriptGenerationEvent(BaseModel): + """ + 脚本生成 SSE 事件 + + 与前端 ScriptGenerationEvent 对应 + + 事件类型说明: + - start: 初始化(0-5%) + - analyzing: 分析主题(5-15%) + - generating: AI 生成中(15-85%) + - validating: 验证格式(85-92%) + - parsing: 解析分镜(92-98%) + - finalizing: 整理结果(98-100%) + - complete: 完成(100%) + - error: 错误 + """ + + type: str = Field( + ..., + description="事件类型:start / analyzing / generating / validating / parsing / finalizing / complete / error", + ) + progress: int = Field(default=0, ge=0, le=100, description="进度百分比") + message: str = Field(..., description="状态描述") + result: list[Any] | None = Field(None, description="生成的分镜结果(complete 时)") + extracted_info: dict[str, Any] | None = Field( + None, description="提取的视频信息(complete 时,如果是视频链接)" + ) + + +class ModelHealthInfo(BaseModel): + """模型健康信息""" + + id: str = Field(..., description="模型 ID") + name: str = Field(..., description="模型名称") + is_available: bool = Field(..., description="是否可用") + response_time: float = Field(..., description="响应时间(毫秒)") + last_error: str | None = Field(None, description="上次错误信息") + + +class ModelHealthResponse(BaseModel): + """模型健康检查响应""" + + status: str = Field(..., description="整体状态:healthy / unhealthy / error") + models: list[ModelHealthInfo] = Field(..., description="各模型状态") + recommended_model: ModelHealthInfo | None = Field(None, description="推荐的模型") + total_models: int = Field(..., description="模型总数") + available_models: int = Field(..., description="可用模型数") + error: str | None = Field(None, description="错误信息") + + +class TestModelRequest(BaseModel): + """测试模型请求""" + + model_id: str | None = Field(None, description="要测试的模型 ID") + + +class TestModelResponse(BaseModel): + """测试模型响应""" + + success: bool = Field(..., description="是否成功") + model: str = Field(..., description="模型名称") + response_time: float | None = Field(None, description="响应时间(毫秒)") + error: str | None = Field(None, description="错误信息") + checked_at: str | None = Field(None, description="检查时间 ISO 格式") diff --git a/python-api/app/schemas/segment.py b/python-api/app/schemas/segment.py new file mode 100644 index 0000000..604bb53 --- /dev/null +++ b/python-api/app/schemas/segment.py @@ -0,0 +1,48 @@ +""" +分镜(Segment)统一 Schema +========================== + +业务层唯一分镜模型,取代以下历史重复定义: +- ScriptShot(script.py) +- ShotData(api/v1/video.py) +- ShotTask(services/kling_video_service.py) +- ShotUnit(scheduler/models.py) +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +from app.schemas.enums import SegmentStatus + + +class Segment(BaseModel): + """视频分镜/镜头定义 + + 术语说明: + - segment: 分镜(带数字人) + - empty_shot: 空镜(无数字人) + """ + + id: str = Field(..., description="分镜ID") + type: Literal["segment", "empty_shot"] = Field( + default="segment", description="分镜类型: segment(分镜) 或 empty_shot(空镜)" + ) + scene: str = Field(default="", description="场景描述/画面描述") + voiceover: str = Field(default="", description="配音文案(空镜可为空)") + duration: int | None = Field(default=None, description="时长(秒)") + human_id: str | None = Field(default=None, description="数字人主体ID") + voice_id: str | None = Field(default=None, description="音色ID(空镜时使用)") + status: SegmentStatus = Field(default=SegmentStatus.PENDING) + provider_task_id: str | None = Field( + default=None, description="供应商任务ID(如 Kling task_id)" + ) + video_url: str | None = Field(default=None, description="生成后的视频URL") + local_path: str | None = Field(default=None, description="本地视频路径") + qiniu_url: str | None = Field(default=None, description="七牛云URL") + error_message: str | None = Field(default=None, description="错误信息") + stage: str | None = Field( + default=None, description="内部处理阶段(如 image_generating / video_processing)" + ) + image_task_id: str | None = Field(default=None, description="空镜文生图任务ID(内部使用)") + query_fail_count: int = Field(default=0, description="查询失败计数") diff --git a/python-api/app/services/__init__.py b/python-api/app/services/__init__.py new file mode 100644 index 0000000..0a4f867 --- /dev/null +++ b/python-api/app/services/__init__.py @@ -0,0 +1,14 @@ +""" +服务层导出 +========== +""" + +from app.services.kling_video_service import KlingVideoService, get_kling_video_service +from app.services.script_service import ScriptService, get_script_service + +__all__ = [ + "ScriptService", + "get_script_service", + "KlingVideoService", + "get_kling_video_service", +] diff --git a/python-api/app/services/ai_response_utils.py b/python-api/app/services/ai_response_utils.py new file mode 100644 index 0000000..6321dcd --- /dev/null +++ b/python-api/app/services/ai_response_utils.py @@ -0,0 +1,331 @@ +""" +AI 响应处理工具 +=============== + +提供安全的 AI 响应解析、验证和清洗功能。 +这是 AI 输出和后端/前端之间的防火墙。 +""" + +import json +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + + +def extract_json_from_markdown(content: str) -> str | None: + """ + 从 Markdown 代码块中提取 JSON 字符串 + + 支持格式: + - ```json {...} ``` + - ``` {...} ``` + - 纯 JSON 文本 + + Args: + content: 原始内容 + + Returns: + 提取的 JSON 字符串,如果无法提取则返回 None + """ + if not content: + return None + + content = content.strip() + + # 匹配 ```json ... ``` 或 ``` ... ``` + pattern = r"```(?:json)?\s*([\s\S]*?)\s*```" + matches = re.findall(pattern, content) + + if matches: + # 取最后一个匹配(避免前面有示例代码) + result = matches[-1].strip() + return result if result else None + + # 如果没有代码块,返回原始内容 + return content + + +def sanitize_string(value: Any, max_length: int = 5000) -> str | None: + """ + 清洗字符串值 + + - 去除 HTML 标签 + - 去除控制字符 + - 标准化空白字符 + - 截断超长内容 + + Args: + value: 原始值 + max_length: 最大长度 + + Returns: + 清洗后的字符串 + """ + if value is None: + return None + + # 转换为字符串 + text = str(value) + + # 去除 HTML 标签 + text = re.sub(r"<[^>]+>", "", text) + + # 去除控制字符(保留换行和制表符) + text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text) + + # 标准化空白字符 + text = re.sub(r"[\t ]+", " ", text) + text = re.sub(r"\n+", "\n", text) + text = text.strip() + + # 截断超长内容 + if len(text) > max_length: + logger.warning(f"内容被截断: {len(text)} -> {max_length} 字符") + text = text[:max_length] + "..." + + return text + + +def parse_duration(duration_value: Any) -> str: + """ + 解析时长字段 + + 支持格式: + - 数字 (5) -> "5s" + - 字符串带单位 ("5s", "5秒") -> "5s" + - 其他 -> "5s" (默认) + + Args: + duration_value: 原始时长值 + + Returns: + 标准化的时长字符串 + """ + if duration_value is None: + return "5s" + + # 如果是数字,直接加 s + if isinstance(duration_value, int | float): + seconds = max(1, min(int(duration_value), 300)) # 限制 1-300 秒 + return f"{seconds}s" + + # 如果是字符串 + text = str(duration_value).strip().lower() + + # 提取数字 + match = re.search(r"(\d+)", text) + if match: + seconds = int(match.group(1)) + seconds = max(1, min(seconds, 300)) + return f"{seconds}s" + + return "5s" + + +def validate_and_normalize_shots(raw_data: Any) -> list[dict[str, Any]]: + """ + 验证并标准化分镜数据 + + 这是一个防御性函数,处理各种可能的 AI 返回格式: + - 列表格式: [{...}, {...}] + - 包装格式: {"shots": [...]} -> 提取 shots + - 单对象格式: {...} -> 包装为列表 + - 无效格式: 返回空列表 + + Args: + raw_data: AI 返回的原始数据 + + Returns: + 标准化的分镜列表 + """ + if raw_data is None: + logger.warning("AI 返回数据为空") + return [] + + shots = [] + + # 处理字典格式(可能是包装对象) + if isinstance(raw_data, dict): + # 尝试提取常见的包装字段 + for key in ["shots", "data", "segments", "script", "result", "list"]: + if key in raw_data and isinstance(raw_data[key], list): + shots = raw_data[key] + logger.info(f"从字典字段 '{key}' 提取到 {len(shots)} 个分镜") + break + else: + # 没有列表字段,将整个字典作为一个分镜 + logger.info("将字典作为单个分镜处理") + shots = [raw_data] + + # 处理列表格式 + elif isinstance(raw_data, list): + shots = raw_data + + # 其他格式无法处理 + else: + logger.error(f"无法处理的 AI 返回格式: {type(raw_data)}") + return [] + + # 验证并标准化每个分镜 + normalized_shots = [] + for idx, item in enumerate(shots): + if not isinstance(item, dict): + logger.warning(f"跳过非字典分镜 (索引 {idx}): {type(item)}") + continue + + # 字段映射和清洗 + normalized: dict[str, Any] = { + "id": str(idx + 1), # 强制按索引递增,Segment 模型要求 str + "type": "segment", # 默认类型 + "scene": None, + "voiceover": "", + "duration": 5, # Segment 模型要求 int(秒) + } + + # 提取 ID(支持字符串和数字,最终转为 str) + raw_id = item.get("id", idx + 1) + try: + normalized["id"] = str(int(raw_id)) + except (ValueError, TypeError): + normalized["id"] = str(idx + 1) + + # 提取类型 + raw_type = item.get("type", "segment") + if isinstance(raw_type, str): + normalized["type"] = raw_type.strip().lower() + + # 提取场景描述(支持多种字段名) + scene = ( + item.get("scene") + or item.get("prompt") + or item.get("description") + or item.get("image_prompt") + or item.get("visual") + ) + normalized["scene"] = sanitize_string(scene, max_length=2000) + + # 提取配音文案(支持多种字段名) + voiceover = ( + item.get("voiceover") + or item.get("text") + or item.get("content") + or item.get("narration") + or item.get("script") + ) + normalized["voiceover"] = sanitize_string(voiceover, max_length=2000) or "" + + # 提取时长(Segment 模型要求 int 秒数) + duration = item.get("duration") + duration_str = parse_duration(duration) # 返回如 "5s" + try: + normalized["duration"] = int(re.search(r"\d+", duration_str).group()) + except (AttributeError, ValueError): + normalized["duration"] = 5 + + # 计算字数 + normalized["word_count"] = len(normalized["voiceover"]) + + normalized_shots.append(normalized) + + return normalized_shots + + +def _normalize_json_quotes(json_str: str) -> str: + """ + 将中文引号(弯引号)替换为英文引号 + + 某些 AI 模型会在长文本生成中混用中英文标点,导致 JSON 解析失败。 + 此函数将中文引号 " 和 " 替换为标准 JSON 使用的英文引号 "。 + + Args: + json_str: 原始 JSON 字符串 + + Returns: + 规范化后的 JSON 字符串 + """ + # 中文左双引号 " 和右双引号 " 都替换为英文双引号 " + return json_str.replace('"', '"').replace('"', '"') + + +def safe_parse_ai_json_response(content: str) -> tuple[bool, Any, str | None]: + """ + 安全地解析 AI JSON 响应 + + Args: + content: AI 返回的原始内容 + + Returns: + tuple: (是否成功, 解析后的数据, 错误信息) + """ + if not content or not content.strip(): + return False, None, "AI 返回内容为空" + + # 提取 JSON 字符串 + json_str = extract_json_from_markdown(content) + + if not json_str: + logger.error(f"无法从内容中提取 JSON: {content[:200]}...") + return False, None, "无法从 AI 输出中提取 JSON" + + # 尝试直接解析 JSON + try: + data = json.loads(json_str) + return True, data, None + except json.JSONDecodeError: + pass # 解析失败,尝试修复 + + # 尝试修复中文引号问题 + normalized = _normalize_json_quotes(json_str) + + try: + data = json.loads(normalized) + logger.info("JSON 引号规范化成功") + return True, data, None + except json.JSONDecodeError as e: + logger.error(f"JSON 解析失败: {e}") + logger.error(f"原始内容前 500 字符: {json_str[:500]!r}") + return False, None, f"JSON 解析失败: {e}" + except Exception as e: + logger.error(f"解析 AI 响应时发生未知错误: {e}") + return False, None, f"解析错误: {e}" + + +def validate_shots_structure(shots: list[dict]) -> tuple[bool, list[str]]: + """ + 验证分镜列表的结构完整性 + + Args: + shots: 分镜列表 + + Returns: + tuple: (是否有效, 错误信息列表) + """ + errors = [] + + if not shots: + errors.append("分镜列表为空") + return False, errors + + for idx, shot in enumerate(shots): + # 检查必需字段 + if not isinstance(shot, dict): + errors.append(f"分镜 {idx + 1} 不是字典类型") + continue + + # 检查 voiceover(允许为空字符串但不允许缺失) + if "voiceover" not in shot: + errors.append(f"分镜 {idx + 1} 缺少 voiceover 字段") + + # 检查 id + if "id" not in shot: + errors.append(f"分镜 {idx + 1} 缺少 id 字段") + elif not isinstance(shot.get("id"), int): + errors.append(f"分镜 {idx + 1} 的 id 不是整数") + + # 检查 type + if "type" not in shot: + errors.append(f"分镜 {idx + 1} 缺少 type 字段") + + return len(errors) == 0, errors diff --git a/python-api/app/services/anytocopy_service.py b/python-api/app/services/anytocopy_service.py new file mode 100644 index 0000000..2873fdd --- /dev/null +++ b/python-api/app/services/anytocopy_service.py @@ -0,0 +1,350 @@ +""" +AnyToCopy 视频文案提取服务 +============================ + +支持 50+ 平台视频文案提取、视频去水印 +文档: https://www.anytocopy.com/account/api/docs +""" + +import asyncio +import logging +import re + +import aiohttp +from pydantic import BaseModel, Field + +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +class AnyToCopyConfig(BaseModel): + """AnyToCopy 配置""" + + api_key: str = Field(default="", description="API Key") + api_secret: str = Field(default="", description="API Secret") + base_url: str = Field( + default="https://api.anytocopy.com/vip/open-api/v1", description="API Base URL" + ) + + +class VideoExtractResult(BaseModel): + """视频提取结果""" + + task_id: str + title: str = "" + content: str = "" + text_content: str = "" # 语音转文字文案 + video_url: str = "" + audio_url: str = "" + cover: str = "" + platform: str = "" + duration: float = 0.0 + status: str = "" # WAITING, SUCCESS, FAILURE + error_message: str = "" + + +class AnyToCopyService: + """ + AnyToCopy 视频文案提取服务 + + 支持平台: + - 小红书 (xhs) + - 抖音 (douyin) + - 快手 (kuaishou) + - 等 50+ 平台 + """ + + # 支持的视频平台链接正则 + PLATFORM_PATTERNS = { + "xiaohongshu": [ + r"https?://(www\.)?xiaohongshu\.com/.*", + r"https?://xhslink\.com/[a-zA-Z0-9_-]+", + r"https?://(www\.)?xhs\.cn/.*", + ], + "douyin": [ + r"https?://(www\.)?douyin\.com/.*", + r"https?://v\.douyin\.com/[a-zA-Z0-9_-]+", + r"https?://(www\.)?iesdouyin\.com/.*", + ], + "kuaishou": [ + r"https?://(www\.)?kuaishou\.com/.*", + r"https?://v\.kuaishou\.com/[a-zA-Z0-9_-]+", + ], + "bilibili": [ + r"https?://(www\.)?bilibili\.com/.*", + r"https?://b23\.tv/[a-zA-Z0-9_-]+", + ], + "weibo": [ + r"https?://(www\.)?weibo\.com/.*", + r"https?://m\.weibo\.cn/.*", + ], + } + + def __init__(self, config: dict | None = None): + self.config = config or {} + self.api_key = self.config.get("api_key", "") + self.api_secret = self.config.get("api_secret", "") + self.base_url = self.config.get("base_url", "https://api.anytocopy.com/vip/open-api/v1") + + def _get_headers(self) -> dict[str, str]: + """获取请求头""" + return { + "X-API-Key": self.api_key, + "X-API-Secret": self.api_secret, + "Content-Type": "application/json", + } + + @classmethod + def is_video_url(cls, text: str) -> bool: + """ + 检测文本是否为视频链接 + + Args: + text: 输入文本 + + Returns: + bool: 是否为视频链接 + """ + if not text or not isinstance(text, str): + return False + + text = text.strip() + + # 检查是否匹配任一平台链接模式 + for platform, patterns in cls.PLATFORM_PATTERNS.items(): + for pattern in patterns: + if re.match(pattern, text, re.IGNORECASE): + return True + + return False + + @classmethod + def extract_url_from_text(cls, text: str) -> str | None: + """ + 从文本中提取视频链接 + + Args: + text: 可能包含链接的文本 + + Returns: + str | None: 提取的链接或 None + """ + if not text or not isinstance(text, str): + return None + + # URL 正则匹配(排除中文标点和常见标点) + url_pattern = r"https?://[a-zA-Z0-9._~:/?#\[\]@!$&'()*+,;=%-]+" + urls = re.findall(url_pattern, text) + + for url in urls: + # 清理尾部标点 + url = url.rstrip("。,!?;:" "''()【】、") + if cls.is_video_url(url): + return url + + return None + + async def submit_task(self, work_url: str, task_type: str = "TEXT") -> dict: + """ + 提交视频文案提取任务 + + Args: + work_url: 作品链接 + task_type: 任务类型,默认 TEXT + + Returns: + dict: 包含 taskId 或错误信息 + """ + if not self.api_key or not self.api_secret: + return {"code": 500, "msg": "AnyToCopy API Key 未配置"} + + url = f"{self.base_url}/video/extract" + params = {"workUrl": work_url, "taskType": task_type} + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=self._get_headers(), params=params) as resp: + data = await resp.json() + logger.info(f"AnyToCopy submit_task response: {data}") + return data + except Exception as e: + logger.error(f"AnyToCopy submit_task error: {e}") + return {"code": 500, "msg": f"请求失败: {str(e)}"} + + async def query_task(self, task_id: str) -> dict: + """ + 查询任务状态和结果 + + Args: + task_id: 任务 ID + + Returns: + dict: 任务状态和结果 + """ + url = f"{self.base_url}/video/query" + params = {"taskId": task_id} + + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=self._get_headers(), params=params) as resp: + data = await resp.json() + return data + except Exception as e: + logger.error(f"AnyToCopy query_task error: {e}") + return {"code": 500, "msg": f"请求失败: {str(e)}"} + + async def extract_video_content( + self, + work_url: str, + max_retries: int = 60, + poll_interval: float = 3.0, + ) -> VideoExtractResult: + """ + 完整的视频提取流程(提交 + 轮询) + + Args: + work_url: 作品链接 + max_retries: 最大轮询次数 + poll_interval: 轮询间隔(秒) + + Returns: + VideoExtractResult: 提取结果 + """ + # 1. 提交任务 + submit_result = await self.submit_task(work_url) + if submit_result.get("code") != 200: + return VideoExtractResult( + task_id="", + status="FAILURE", + error_message=submit_result.get("msg", "提交任务失败"), + ) + + task_id = submit_result["data"] + logger.info(f"AnyToCopy task submitted, taskId: {task_id}") + + # 2. 轮询查询 + for i in range(max_retries): + await asyncio.sleep(poll_interval) + + query_result = await self.query_task(task_id) + if query_result.get("code") != 200: + continue + + data = query_result.get("data", {}) + status = data.get("status") + + if status == "SUCCESS": + logger.info(f"AnyToCopy task {task_id} completed successfully") + return VideoExtractResult( + task_id=task_id, + title=data.get("title", ""), + content=data.get("content", ""), + text_content=data.get("textContent", ""), + video_url=data.get("videoUrl", ""), + audio_url=data.get("audioUrl", ""), + cover=data.get("cover", ""), + platform=data.get("platform", ""), + duration=data.get("duration", 0.0), + status="SUCCESS", + error_message=data.get("errorMessage", ""), + ) + elif status in ("FAILURE", "FAILED"): + logger.error(f"AnyToCopy task {task_id} failed: {data.get('errorMessage')}") + return VideoExtractResult( + task_id=task_id, + status="FAILURE", + error_message=data.get("errorMessage", "任务执行失败"), + ) + else: + logger.debug( + f"AnyToCopy task {task_id} status: {status}, retry {i+1}/{max_retries}" + ) + + # 轮询超时 + logger.warning(f"AnyToCopy task {task_id} polling timeout") + return VideoExtractResult( + task_id=task_id, + status="TIMEOUT", + error_message="轮询超时,任务未完成", + ) + + async def extract_text_from_input(self, user_input: str) -> dict: + """ + 智能提取输入中的文案 + + - 如果是视频链接,提取视频文案 + - 如果不是链接,返回原文 + + Args: + user_input: 用户输入(可能是链接或文案) + + Returns: + dict: { + "is_video_url": bool, + "original_input": str, + "extracted_text": str, + "video_info": VideoExtractResult | None, + "error": str | None, + } + """ + result = { + "is_video_url": False, + "original_input": user_input, + "extracted_text": user_input, + "video_info": None, + "error": None, + } + + # 检查是否为视频链接 + url = self.extract_url_from_text(user_input) + if not url: + # 不是链接,直接返回原文 + return result + + # 是视频链接,提取文案 + result["is_video_url"] = True + + if not self.api_key or not self.api_secret: + result["error"] = "AnyToCopy API Key 未配置,无法提取视频文案" + return result + + try: + video_result = await self.extract_video_content(url) + result["video_info"] = video_result + + if video_result.status == "SUCCESS": + # 优先使用语音转文字文案,其次使用正文内容 + extracted_text = ( + video_result.text_content or video_result.content or video_result.title + ) + result["extracted_text"] = extracted_text + logger.info(f"AnyToCopy extracted text length: {len(extracted_text)}") + else: + result["error"] = video_result.error_message or "视频文案提取失败" + + except Exception as e: + logger.error(f"AnyToCopy extract_text_from_input error: {e}") + result["error"] = f"提取失败: {str(e)}" + + return result + + +# 全局单例 +_anytocopy_service: AnyToCopyService | None = None + + +def get_anytocopy_service() -> AnyToCopyService: + """获取 AnyToCopyService 单例""" + global _anytocopy_service + if _anytocopy_service is None: + # 从 Settings 加载配置 + settings = get_settings() + + config = { + "api_key": settings.ANYTOCOPY_API_KEY or "", + "api_secret": settings.ANYTOCOPY_API_SECRET or "", + "base_url": settings.ANYTOCOPY_BASE_URL, + } + _anytocopy_service = AnyToCopyService(config) + return _anytocopy_service diff --git a/python-api/app/services/ass_generator.py b/python-api/app/services/ass_generator.py new file mode 100644 index 0000000..7886704 --- /dev/null +++ b/python-api/app/services/ass_generator.py @@ -0,0 +1,113 @@ +""" +ASS 字幕生成器 +============== + +生成带样式的 ASS 字幕文件,使用抖音美好体 (DouyinSans)。 +""" + +from __future__ import annotations + +from app.schemas.caption import CaptionUtterance + + +def generate_ass( + utterances: list[CaptionUtterance], + video_width: int = 1080, + video_height: int = 1920, + fontname: str = "DouyinSansBold", + fontsize: float = 28.0, + primary_color: str = "&H00FFFFFF", # 白色 + outline_color: str = "&H00000000", # 黑色描边 + back_color: str = "&H80000000", # 半透明背景 + outline: float = 2.0, + shadow: float = 1.0, + margin_v: int = 50, +) -> str: + """ + 生成 ASS 字幕内容 + + Args: + utterances: 字幕时间轴列表 + video_width: 视频宽度 + video_height: 视频高度 + fontname: 字体名称(默认为 DouyinSansBold) + fontsize: 字体大小 + primary_color: 主颜色 (&HAABBGGRR 格式) + outline_color: 描边颜色 + back_color: 背景颜色 + outline: 描边宽度 + shadow: 阴影深度 + margin_v: 垂直边距 + + Returns: + ASS 格式字符串 + """ + # Script Info 部分 + script_info = f"""[Script Info] +Title: Generated by Meijiaka AI Video +ScriptType: v4.00+ +PlayResX: {video_width} +PlayResY: {video_height} +ScaledBorderAndShadow: yes +YCbCr Matrix: None + +""" + + # Styles 部分 + styles = f"""[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,{fontname},{fontsize:.1f},{primary_color},&H000000FF,{outline_color},{back_color},0,0,0,0,100,100,0,0,1,{outline:.1f},{shadow:.1f},2,20,20,{margin_v},1 + +""" + + # Events 部分 + events_header = """[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + + events = [] + for u in utterances: + start = _ms_to_ass_time(u.start_time) + end = _ms_to_ass_time(u.end_time) + # 转义特殊字符 + text = u.text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}") + event = f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}" + events.append(event) + + return script_info + styles + events_header + "\n".join(events) + + +def _ms_to_ass_time(ms: int) -> str: + """毫秒转换为 ASS 时间格式 H:MM:SS.cc""" + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + centiseconds = (ms % 1000) // 10 + return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}" + + +# 预设样式 +def generate_ass_short_video(utterances: list[CaptionUtterance]) -> str: + """生成短视频风格的 ASS 字幕(竖屏 9:16)""" + return generate_ass( + utterances=utterances, + video_width=1080, + video_height=1920, + fontsize=32.0, + outline=2.5, + shadow=2.0, + margin_v=80, + ) + + +def generate_ass_professional(utterances: list[CaptionUtterance]) -> str: + """生成专业风格的 ASS 字幕(横屏 16:9)""" + return generate_ass( + utterances=utterances, + video_width=1920, + video_height=1080, + fontsize=24.0, + outline=1.5, + shadow=1.0, + margin_v=60, + ) diff --git a/python-api/app/services/kling_video_service.py b/python-api/app/services/kling_video_service.py new file mode 100644 index 0000000..2d5d3a2 --- /dev/null +++ b/python-api/app/services/kling_video_service.py @@ -0,0 +1,811 @@ +""" +Kling 视频生成服务 +================== + +提供视频生成功能,调用 Kling AI API: +- 分镜视频生成(omni-video)- 数字人分镜 +- 空镜视频生成(文生图 + 图生视频)- 空镜场景 +- 并发控制、轮询查询、视频下载 + +存储路径:~/Documents/Meijiaka/projects/{project_id}/videos/ +命名规则:scene_{segment_id}.mp4 + +空镜生成新流程: +1. 使用画面描述调用文生图(kling-v3) +2. 生成的图片保存到七牛云 +3. 使用七牛云图片 URL 调用图生视频(kling-v2-6),指定自定义音色 +""" + +import asyncio +import contextlib +import logging +import tempfile +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import aiohttp + +from app.ai.providers.klingai_provider import KlingAIProvider, KlingPromptBuilder +from app.config import get_settings +from app.core.config_loader import get_config_loader +from app.schemas.enums import JobStatus, SegmentStatus +from app.schemas.segment import Segment +from app.services.qiniu_service import get_qiniu_service + +logger = logging.getLogger(__name__) + + +@dataclass +class VideoGenerationJob: + """视频生成作业""" + + job_id: str + project_id: str + segments: list[Segment] = field(default_factory=list) + status: JobStatus = field(default=JobStatus.PENDING) + progress: int = 0 + created_at: float = field(default_factory=lambda: asyncio.get_event_loop().time()) + updated_at: float = field(default_factory=lambda: asyncio.get_event_loop().time()) + error_message: str | None = None + + +class KlingVideoService: + """Kling 视频生成服务""" + + # 并发控制 + MAX_CONCURRENT = 3 + # 轮询间隔(秒) + POLL_INTERVAL = 5 + # 超时时间(秒)- 10分钟 + TIMEOUT = 600 + + # 视频存储根目录 + BASE_STORAGE_DIR = Path.home() / "Documents" / "Meijiaka" / "projects" + + def __init__(self): + self._provider: KlingAIProvider | None = None + self._semaphore: asyncio.Semaphore | None = None + self._jobs: dict[str, VideoGenerationJob] = {} + + async def _get_provider(self) -> KlingAIProvider: + """获取或初始化 KlingAI Provider + + API Key 从 Settings 读取(符合配置规范) + """ + if self._provider is None: + 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 ValueError("KlingAI 未配置,请设置 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY") + + # 从 YAML 读取 base_url(模型配置) + base_url = platform.base_url if platform else None + + self._provider = KlingAIProvider( + { + "access_key": access_key, + "secret_key": secret_key, + "base_url": base_url or "https://api-beijing.klingai.com", + } + ) + return self._provider + + def _get_semaphore(self) -> asyncio.Semaphore: + """获取并发控制信号量""" + if self._semaphore is None: + self._semaphore = asyncio.Semaphore(self.MAX_CONCURRENT) + return self._semaphore + + def _get_project_video_dir(self, project_id: str) -> Path: + """获取项目视频存储目录""" + video_dir = self.BASE_STORAGE_DIR / project_id / "videos" + video_dir.mkdir(parents=True, exist_ok=True) + return video_dir + + async def _poll_image_task(self, provider: KlingAIProvider, task_id: str) -> str: + """轮询文生图任务状态,直到完成或超时 + + Returns: + 生成的图片 URL + """ + start_time = asyncio.get_event_loop().time() + + while True: + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed > self.TIMEOUT: + raise TimeoutError(f"文生图任务 {task_id} 轮询超时({self.TIMEOUT}秒)") + + result = await provider.get_image_task(task_id) + status = result.get("task_status", "unknown") + logger.debug(f"文生图任务 {task_id} 状态: {status}") + + if status == "succeed": + # 获取图片 URL + images = result.get("task_result", {}).get("images", []) + if not images: + raise Exception("文生图任务成功但未返回图片") + image_url = images[0].get("url") + if not image_url: + raise Exception("文生图任务成功但未获取到图片URL") + return image_url + + if status == "failed": + error_msg = result.get("task_status_msg", "未知错误") + raise Exception(f"文生图任务失败: {error_msg}") + + # 继续轮询 + await asyncio.sleep(self.POLL_INTERVAL) + + async def _download_image(self, image_url: str, local_path: Path) -> Path: + """下载图片到本地 + + Args: + image_url: 图片 URL + local_path: 本地保存路径 + + Returns: + 本地文件路径 + """ + async with aiohttp.ClientSession() as session, session.get(image_url) as resp: + resp.raise_for_status() + local_path.write_bytes(await resp.read()) + + logger.info(f"图片下载完成: {local_path}") + return local_path + + async def _create_omni_video_task(self, segment: Segment, human_id: int) -> dict[str, Any]: + """创建 Omni-Video 任务(分镜/带数字人)""" + provider = await self._get_provider() + + prompt = KlingPromptBuilder.omni_segment(segment.scene, segment.voiceover) + + # omni-video 参数 + result = await provider.generate_video_omni( + prompt=prompt, + model="kling-v3-omni", + mode="pro", + aspect_ratio="9:16", + duration=segment.duration, + sound="on", + multi_shot=False, + element_list=[{"element_id": str(human_id)}], + ) + + return result + + async def _create_empty_shot_task(self, segment: Segment, voice_id: str) -> dict[str, Any]: + """创建空镜视频任务(流程:文生图 → 上传七牛 → 图生视频) + + 流程: + 1. 使用画面描述调用文生图(model=kling-v3) + 2. 等待图片生成完成 + 3. 下载图片到本地临时文件 + 4. 上传图片到七牛云,获取公开 URL + 5. 使用图片 URL + 画面描述调用图生视频(model=kling-v2-6),指定音色 + + 通过 segment.stage 字段向外部报告当前处理阶段: + - "image_generating": 文生图阶段 + - "image2video_queued": 图生视频任务已创建(排队中) + """ + provider = await self._get_provider() + qiniu = get_qiniu_service() + + # ========== Step 1: 文生图 ========== + logger.info(f"[空镜] Step 1: 文生图开始 - {segment.scene}") + segment.stage = "image_generating" + + # 使用画面描述作为 prompt,固定 9:16 比例 + image_result = await provider.generate_image( + prompt=segment.scene, + model="kling-v3", + aspect_ratio="9:16", + ) + + image_task_id = image_result.get("task_id") + if not image_task_id: + raise ValueError(f"文生图创建失败,未返回 task_id: {image_result}") + + logger.info(f"[空镜] 文生图任务创建成功: {image_task_id}") + + # ========== Step 2: 轮询等待图片生成完成 ========== + image_url = await self._poll_image_task(provider, image_task_id) + logger.info(f"[空镜] 文生图完成: {image_url}") + + # ========== Step 3: 下载图片到本地临时文件 ========== + temp_dir = Path(tempfile.gettempdir()) / "meijiaka_empty_shot" + temp_dir.mkdir(parents=True, exist_ok=True) + temp_image_path = temp_dir / f"{image_task_id}.jpg" + + await self._download_image(image_url, temp_image_path) + logger.info(f"[空镜] 图片下载到本地: {temp_image_path}") + + # ========== Step 4: 上传图片到七牛云 ========== + qiniu_result = qiniu.upload_file( + local_path=str(temp_image_path), + file_type="image", + check_duplicate=True, + ) + qiniu_image_url = qiniu_result["url"] + logger.info(f"[空镜] 图片上传到七牛云: {qiniu_image_url}") + + # 清理临时文件 + with contextlib.suppress(Exception): + temp_image_path.unlink() + + # ========== Step 5: 图生视频 ========== + logger.info(f"[空镜] Step 5: 图生视频开始 - {qiniu_image_url}") + # 更新阶段:图片已生成完成,开始排队生成视频 + # 这个 stage 会被 process_job_stream 检测到并推送 SSE + segment.stage = "image2video_queued" + + # 构建提示词:画面描述 + 配音文案 + prompt = KlingPromptBuilder.empty_shot(segment.scene, segment.voiceover) + + # 调用图生视频 API,model=kling-v2-6 + # 添加 voice_list 指定自定义音色,prompt 中使用 <<>> 引用 + # sound=on 确保音画同出 + # negative_prompt 避免画外音断句问题 + result = await provider.generate_video_image2video( + prompt=prompt, + image_url=qiniu_image_url, + model="kling-v2-6", + mode="pro", + duration=segment.duration, + voice_list=[{"voice_id": voice_id}], + sound="on", + negative_prompt="画外音没有标点的时候不要轻易断句", + ) + + return result + + async def _submit_empty_shot_image(self, segment: Segment) -> dict[str, Any]: + """提交空镜文生图任务(仅提交,不轮询) + + Returns: + Kling API 返回结果,包含 image_task_id + """ + provider = await self._get_provider() + result = await provider.generate_image( + prompt=segment.scene, + model="kling-v3", + aspect_ratio="9:16", + ) + return result + + async def _submit_empty_shot_video(self, segment: Segment, image_url: str) -> dict[str, Any]: + """提交空镜图生视频任务(仅提交,不轮询) + + Args: + segment: 空镜任务 + image_url: 七牛云图片 URL + + Returns: + Kling API 返回结果,包含 video_task_id + """ + provider = await self._get_provider() + voice_id = segment.voice_id or get_settings().DEFAULT_EMPTY_SHOT_VOICE_ID + prompt = KlingPromptBuilder.empty_shot(segment.scene, segment.voiceover) + + result = await provider.generate_video_image2video( + prompt=prompt, + image_url=image_url, + model="kling-v2-6", + mode="pro", + duration=segment.duration, + voice_list=[{"voice_id": voice_id}], + sound="on", + negative_prompt="画外音没有标点的时候不要轻易断句", + ) + return result + + async def _poll_task_status( + self, task_id: str, task_type: str = "omni-video", progress_callback: Any = None + ) -> dict[str, Any]: + """轮询查询任务状态 + + Args: + task_id: Kling AI 任务ID + task_type: 任务类型 (omni-video 或 text2video) + progress_callback: 可选的进度回调,接收 (checked_count: int) 参数 + + Returns: + 任务结果字典 + """ + provider = await self._get_provider() + start_time = asyncio.get_event_loop().time() + check_count = 0 + + while True: + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed > self.TIMEOUT: + raise TimeoutError(f"任务 {task_id} 轮询超时({self.TIMEOUT}秒)") + + try: + if task_type == "omni-video": + result = await provider.get_omni_video_task(task_id) + else: + # text2video, image2video 都使用同一个查询入口 + result = await provider.get_video_task(task_id, task_type=task_type) + + status = result.get("task_status", "unknown") + logger.debug(f"任务 {task_id} 状态: {status}") + + if status in ("succeed", "failed"): + return result + + # 继续轮询 + check_count += 1 + # 每轮询一次就调用回调,让外部有机会 yield 并推送 SSE + if progress_callback: + await progress_callback(check_count) + + await asyncio.sleep(self.POLL_INTERVAL) + + except Exception as e: + logger.error(f"轮询任务 {task_id} 状态失败: {e}") + await asyncio.sleep(self.POLL_INTERVAL) + + async def _download_video(self, video_url: str, local_path: Path) -> Path: + """下载视频到本地 + + Args: + video_url: 视频URL + local_path: 本地存储路径 + + Returns: + 本地文件路径 + """ + async with aiohttp.ClientSession() as session, session.get(video_url) as resp: + resp.raise_for_status() + local_path.write_bytes(await resp.read()) + + logger.info(f"视频下载完成: {local_path}") + return local_path + + async def _process_single_segment(self, segment: Segment, job: VideoGenerationJob) -> Segment: + """处理单个分镜(带并发控制)""" + semaphore = self._get_semaphore() + + async with semaphore: + try: + segment.status = SegmentStatus.PROCESSING + logger.info(f"开始处理分镜 {segment.id} (类型: {segment.type})") + + # 根据分镜类型选择生成方式 + if segment.type == "segment" and segment.human_id: + # 分镜:使用 omni-video + result = await self._create_omni_video_task(segment, int(segment.human_id)) + task_type = "omni-video" + else: + # 空镜:流程 - 文生图 → 上传七牛 → 图生视频 + # 空镜需要 voice_id,优先使用 segment 指定的,否则使用配置中的默认音色 + settings = get_settings() + voice_id = segment.voice_id or settings.DEFAULT_EMPTY_SHOT_VOICE_ID + result = await self._create_empty_shot_task(segment, voice_id) + task_type = "image2video" + + # 获取任务ID + task_id = result.get("task_id") + if not task_id: + raise ValueError(f"创建任务失败,未返回 task_id: {result}") + + segment.provider_task_id = task_id + logger.info(f"分镜 {segment.id} 任务创建成功: {task_id}") + + # 更新 stage:任务已提交,进入队列等待 + # 这个 stage 用于前端显示"进入任务队列" + if segment.type == "segment": + segment.stage = "video_queued" + # 等待 1.5 秒让前端能检测到这个 stage + await asyncio.sleep(1.5) + else: + # 空镜已经是 image2video_queued,不需要再设置 + pass + + # 更新 stage:开始视频生成轮询 + segment.stage = "video_processing" + + # 轮询等待任务完成,传入回调让外部可以定期检查状态 + async def _on_poll_check(check_count: int): + # 每轮询一次就短暂 yield,让事件循环可以处理其他任务 + # 包括让 process_job_stream 的 timeout 触发并检查 stage + await asyncio.sleep(0) + + task_result = await self._poll_task_status( + task_id, task_type, progress_callback=_on_poll_check + ) + + # 检查任务结果 + if task_result.get("task_status") != "succeed": + error_msg = task_result.get("task_status_msg", "未知错误") + raise Exception(f"任务执行失败: {error_msg}") + + # 获取视频URL + videos = task_result.get("task_result", {}).get("videos", []) + if not videos: + raise Exception("任务成功但未返回视频") + + video_url = videos[0].get("url") + if not video_url: + raise Exception("未获取到视频URL") + + segment.video_url = video_url + + # 下载视频到本地 + video_dir = self._get_project_video_dir(job.project_id) + local_filename = f"scene_{segment.id}.mp4" + local_path = video_dir / local_filename + + await self._download_video(video_url, local_path) + + segment.local_path = str(local_path) + + # 上传视频到七牛云(用于字幕生成等后续处理) + try: + qiniu = get_qiniu_service() + qiniu_result = qiniu.upload_video(local_path=str(local_path)) + segment.qiniu_url = qiniu_result["url"] + logger.info(f"分镜 {segment.id} 视频已上传七牛云: {segment.qiniu_url}") + except Exception as e: + logger.error(f"分镜 {segment.id} 上传七牛云失败: {e}") + # 上传失败不影响本地保存,但会记录错误 + segment.qiniu_url = None + + segment.status = SegmentStatus.COMPLETED + + logger.info(f"分镜 {segment.id} 处理完成: {local_path}") + + except Exception as e: + logger.error(f"处理分镜 {segment.id} 失败: {e}") + segment.status = SegmentStatus.FAILED + segment.error_message = str(e) + + return segment + + async def create_job( + self, + project_id: str, + human_id: int | None, + segments_data: list[dict[str, Any]], + ) -> VideoGenerationJob: + """创建视频生成作业 + + Args: + project_id: 项目ID + human_id: 数字人主体ID(分镜类型使用) + segments_data: 分镜数据列表 + + Returns: + 视频生成作业 + """ + import uuid + + job_id = f"video_{uuid.uuid4().hex[:16]}" + + # 构建分镜任务列表 + segments = [] + for idx, segment_data in enumerate(segments_data): + segment = Segment( + id=segment_data.get("id", f"segment_{idx}"), + type=segment_data.get("type", "segment"), + scene=segment_data.get("scene", ""), + voiceover=segment_data.get("voiceover", ""), + human_id=str(human_id) if segment_data.get("type") == "segment" else None, + voice_id=segment_data.get("voice_id"), # 空镜可能需要指定音色 + status=SegmentStatus.PENDING, + ) + segments.append(segment) + + job = VideoGenerationJob( + job_id=job_id, + project_id=project_id, + segments=segments, + ) + + self._jobs[job_id] = job + logger.info(f"创建视频生成作业: {job_id}, 项目: {project_id}, 分镜数: {len(segments)}") + + return job + + async def process_job_stream(self, job_id: str) -> AsyncIterator[dict[str, Any]]: + """流式处理视频生成作业(SSE) + + 进度设计: + - 0-10%: 初始化任务 + - 10-80%: 逐个生成分镜(每个分镜按比例分配) + - 80-95%: 完成处理 + - 95-100%: 最终整理 + """ + job = self._jobs.get(job_id) + if not job: + yield { + "type": "error", + "percent": 0, + "message": "作业不存在", + } + return + + total_segments = len(job.segments) + if total_segments == 0: + yield { + "type": "error", + "percent": 0, + "message": "没有需要处理的分镜", + } + return + + # 计算每个分镜的进度权重 + # 新分配:10% 初始化 + 20% 队列 + 60% 处理 + 10% 验证完成 + queue_weight = 20 / total_segments # 20% 分配给队列阶段 + process_weight = 60 / total_segments # 60% 分配给处理阶段 + + # 计算预计时间(分镜3分钟/个,空镜4分钟/个) + def calculate_estimated_seconds(remaining_segments: list) -> int: + total_minutes = 0 + for segment in remaining_segments: + if segment.type == "empty_shot": + total_minutes += 4 # 空镜4分钟 + else: + total_minutes += 3 # 分镜3分钟 + return total_minutes * 60 # 转换为秒 + + # 1. 初始化阶段 (10%) + yield { + "type": "start", + "percent": 5, + "message": f"开始生成视频,共 {total_segments} 个镜头...", + "job_id": job_id, + } + + await asyncio.sleep(0.5) + + yield { + "type": "processing", + "percent": 10, + "message": "任务初始化...", + } + + job.status = JobStatus.RUNNING + + # 2. 并行处理所有分镜 + pending_tasks: set[asyncio.Task] = set() + for segment in job.segments: + task = asyncio.create_task(self._process_single_segment(segment, job)) + pending_tasks.add(task) + + # 等待所有分镜完成,同时报告进度 + completed_count = 0 + failed_count = 0 + # 跟踪每个空镜的 stage 状态,用于推送细粒度进度 + last_reported_stages: dict[str, str] = {} + last_reported_processing_count = 0 + last_reported_queued_count = 0 + last_progress_yield_time = asyncio.get_event_loop().time() + + while pending_tasks: + # 等待任意一个任务完成,或者超时(用于定期检查 stage) + done_tasks: set[asyncio.Task] = set() + + # 使用 timeout 让事件循环有机会处理其他任务并检查 stage + # 即使没有任何任务完成,也能每 2 秒推送一次进度 + with contextlib.suppress(Exception): + done_tasks, pending_tasks = await asyncio.wait( + pending_tasks, + return_when=asyncio.FIRST_COMPLETED, + timeout=2.0, # 2 秒超时,让出控制权 + ) + + # 检查已完成的任务 + for task in done_tasks: + try: + segment = await task + if segment.status == SegmentStatus.COMPLETED: + completed_count += 1 + else: + failed_count += 1 + except Exception as e: + failed_count += 1 + logger.error(f"分镜任务异常: {e}") + + # 计算各阶段数量 + queued_count = sum( + 1 + for s in job.segments + if s.status == SegmentStatus.PROCESSING + and s.stage in ("video_queued", "image2video_queued") + ) + processing_count = sum( + 1 + for s in job.segments + if s.status == SegmentStatus.PROCESSING and s.stage == "video_processing" + ) + + # 计算进度:10% + 队列阶段 + 处理阶段 + # 队列阶段:最多 20%,按在队列中的数量计算 + # 处理阶段:最多 60%,按已完成+失败数量计算 + queue_progress = min(20, int(queued_count * queue_weight)) + process_progress = min(60, int((completed_count + failed_count) * process_weight)) + current_progress = 10 + queue_progress + process_progress + job.progress = min(current_progress, 80) + + # 检查是否需要推送进度更新(stage变化或数量变化) + stage_changed = False + count_changed = ( + processing_count != last_reported_processing_count + or queued_count != last_reported_queued_count + ) + + for segment in job.segments: + if ( + segment.status == SegmentStatus.PROCESSING + and segment.stage + and segment.stage != last_reported_stages.get(segment.id) + ): + last_reported_stages[segment.id] = segment.stage + stage_changed = True + + # 如果 stage 变化或数量变化,推送更新 + if stage_changed or count_changed: + last_reported_processing_count = processing_count + last_reported_queued_count = queued_count + + # 确定当前主要阶段和消息 + remaining_segments = [ + s for s in job.segments if s.status != SegmentStatus.COMPLETED + ] + if queued_count > 0: + yield { + "type": "processing", + "percent": job.progress, + "message": f"进入任务队列({queued_count}/{total_segments})...", + "completed": completed_count, + "failed": failed_count, + "total": total_segments, + "estimatedSeconds": calculate_estimated_seconds(remaining_segments), + } + elif processing_count > 0: + yield { + "type": "processing", + "percent": job.progress, + "message": f"视频生成中({processing_count}/{total_segments})...", + "completed": completed_count, + "failed": failed_count, + "total": total_segments, + "estimatedSeconds": calculate_estimated_seconds(remaining_segments), + } + + # 如果有任务完成,推送整体进度 + if done_tasks: + # 计算剩余镜头的预计时间 + remaining_segments = [ + s for s in job.segments if s.status != SegmentStatus.COMPLETED + ] + yield { + "type": "processing", + "percent": job.progress, + "message": f"已完成 {completed_count}/{total_segments} 个分镜" + + (f",{failed_count} 个失败" if failed_count > 0 else ""), + "completed": completed_count, + "failed": failed_count, + "total": total_segments, + "estimatedSeconds": calculate_estimated_seconds(remaining_segments), + } + last_progress_yield_time = asyncio.get_event_loop().time() + + # 即使没有任务完成,也定期检查 stage(每3秒至少推送一次) + # 这确保了在视频轮询期间,前端仍能收到状态更新 + current_time = asyncio.get_event_loop().time() + if ( + not done_tasks + and not stage_changed + and (current_time - last_progress_yield_time > 3.0) + ): + # 检查是否有分镜正在视频处理阶段,保持前端状态活跃 + has_video_processing = any( + s.status == SegmentStatus.PROCESSING and s.stage == "video_processing" + for s in job.segments + ) + if has_video_processing: + # 计算剩余镜头的预计时间 + remaining_segments = [ + s for s in job.segments if s.status != SegmentStatus.COMPLETED + ] + yield { + "type": "processing", + "percent": job.progress, + "message": "视频生成中...", + "completed": completed_count, + "failed": failed_count, + "total": total_segments, + "estimatedSeconds": calculate_estimated_seconds(remaining_segments), + } + last_progress_yield_time = current_time + # 短暂 sleep 避免 CPU 空转 + await asyncio.sleep(0.5) + + # 3. 完成阶段 (90-100%) + yield { + "type": "processing", + "percent": 90, + "message": "验证文件...", + } + + await asyncio.sleep(0.5) + + if failed_count == 0: + job.status = JobStatus.COMPLETED + elif completed_count == 0: + job.status = JobStatus.FAILED + else: + job.status = JobStatus.FAILED + job.progress = 100 + job.updated_at = asyncio.get_event_loop().time() + + # 构建结果 + results = [] + for segment in job.segments: + results.append( + { + "segment_id": segment.id, + "type": segment.type, + "status": segment.status.value, + "task_id": segment.provider_task_id, + "video_url": segment.video_url, + "local_path": segment.local_path, + "qiniu_url": segment.qiniu_url, # 七牛云 URL(用于字幕生成等后续处理) + "error_message": segment.error_message, + } + ) + + yield { + "type": "complete", + "percent": 100, + "message": f"任务完成。成功生成 {completed_count} 个视频。", + "job_id": job_id, + "results": results, + } + + def get_job(self, job_id: str) -> VideoGenerationJob | None: + """获取作业信息""" + return self._jobs.get(job_id) + + def get_job_status(self, job_id: str) -> dict[str, Any | None] | None: + """获取作业状态""" + job = self._jobs.get(job_id) + if not job: + return None + + return { + "job_id": job.job_id, + "project_id": job.project_id, + "status": job.status.value, + "progress": job.progress, + "total_segments": len(job.segments), + "completed_segments": sum( + 1 for s in job.segments if s.status == SegmentStatus.COMPLETED + ), + "failed_segments": sum(1 for s in job.segments if s.status == SegmentStatus.FAILED), + "created_at": job.created_at, + "updated_at": job.updated_at, + "error_message": job.error_message, + } + + +# 全局单例 +_kling_video_service: KlingVideoService | None = None + + +def get_kling_video_service() -> KlingVideoService: + """获取 KlingVideoService 单例""" + global _kling_video_service + if _kling_video_service is None: + _kling_video_service = KlingVideoService() + return _kling_video_service diff --git a/python-api/app/services/qiniu_service.py b/python-api/app/services/qiniu_service.py new file mode 100644 index 0000000..152c42c --- /dev/null +++ b/python-api/app/services/qiniu_service.py @@ -0,0 +1,486 @@ +""" +七牛云对象存储服务 +==================== + +提供音频、视频文件的上传、管理和访问功能。 + +使用场景: +1. 声音克隆 - 上传音频样本文件 +2. 音频生成 - 存储 TTS 生成的音频 +3. 视频素材 - 上传视频文件用于后续处理 +""" + +import mimetypes +import uuid +from datetime import datetime +from pathlib import Path +from typing import BinaryIO + +from qiniu import Auth, BucketManager, CdnManager, put_file, put_stream + +from app.config import get_settings + + +class QiniuService: + """ + 七牛云服务封装 + + 封装了常用的文件上传、下载、管理操作, + 专为美家卡智影项目的音视频文件处理场景设计。 + """ + + # 文件类型目录映射 + TYPE_DIRECTORIES = { + "audio": "audios", + "video": "videos", + "image": "images", + "avatar": "avatars", # 形象克隆视频 + } + + # 允许的文件类型 + ALLOWED_AUDIO_TYPES = { + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/x-m4a", + "audio/aac", + "audio/ogg", + } + ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"} + ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} + + def __init__(self): + """ + 初始化七牛云服务 + + 支持多 bucket: + - 图片: img-liche / img.liche.cn + - 视频/音频: media-liche / media.liche.cn + """ + settings = get_settings() + self.access_key = settings.QINIU_ACCESS_KEY + self.secret_key = settings.QINIU_SECRET_KEY + + # 图片 bucket 配置 + self.image_bucket = settings.QINIU_IMAGE_BUCKET + self.image_domain = settings.QINIU_IMAGE_DOMAIN + + # 视频/音频 bucket 配置 + self.video_bucket = settings.QINIU_VIDEO_BUCKET + self.video_domain = settings.QINIU_VIDEO_DOMAIN + + if not all([self.access_key, self.secret_key]): + raise ValueError( + "七牛云配置不完整,请设置环境变量: " "QINIU_ACCESS_KEY, QINIU_SECRET_KEY" + ) + + # 初始化认证和管理器 + self.auth = Auth(self.access_key, self.secret_key) + self.bucket = BucketManager(self.auth) + self.cdn = CdnManager(self.auth) + + def _get_bucket_and_domain(self, file_type: str) -> tuple[str, str]: + """ + 根据文件类型获取对应的 bucket 和 domain + + Args: + file_type: 文件类型 (audio/video/image/avatar) + + Returns: + (bucket_name, domain) + """ + if file_type == "image": + return self.image_bucket, self.image_domain + # video, avatar, audio 都用视频 bucket + return self.video_bucket, self.video_domain + + # 项目前缀 + PROJECT_PREFIX = "meijiaka" + + def generate_key(self, file_type: str, original_filename: str, user_id: str = None) -> str: + """ + 生成规范的文件存储路径 + + 格式: meijiaka/{type}/{date}/{uuid}.{ext} + + Args: + file_type: 文件类型 (audio/video/image/voice_clone/tts_output) + original_filename: 原始文件名 + user_id: 用户ID(可选,用于目录隔离) + + Returns: + 文件存储 Key + """ + # 获取文件扩展名 + ext = Path(original_filename).suffix.lower() + if not ext: + # 根据 file_type 设置默认扩展名 + ext_map = {"audio": ".mp3", "video": ".mp4", "image": ".jpg"} + ext = ext_map.get(file_type, ".bin") + + # 生成唯一标识 + unique_id = str(uuid.uuid4())[:12] + + # 获取类型目录 + type_dir = self.TYPE_DIRECTORIES.get(file_type, "others") + + # 构建路径(带项目前缀) + date_str = datetime.now().strftime("%Y%m") + + if user_id: + return f"{self.PROJECT_PREFIX}/{type_dir}/{user_id}/{date_str}/{unique_id}{ext}" + return f"{self.PROJECT_PREFIX}/{type_dir}/{date_str}/{unique_id}{ext}" + + def validate_file_type(self, mime_type: str, allowed_types: set) -> bool: + """验证文件 MIME 类型是否在允许列表中""" + return mime_type in allowed_types + + def get_upload_token( + self, bucket: str, key: str, expires: int = 3600, policy: dict = None + ) -> str: + """ + 生成上传凭证(客户端直传使用) + + Args: + bucket: 存储空间名称 + key: 文件存储 Key + expires: Token 有效期(秒),默认 1 小时 + policy: 自定义上传策略(可选) + + Returns: + 上传 Token 字符串 + """ + return self.auth.upload_token(bucket, key, expires, policy) + + def _calculate_file_hash(self, local_path: Path) -> str: + """计算文件的 MD5 哈希""" + import hashlib + + md5 = hashlib.md5() + with open(local_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + md5.update(chunk) + return md5.hexdigest() + + def _find_file_by_hash(self, bucket: str, file_hash: str) -> dict | None: + """ + 根据文件哈希查找已存在的文件 + + Args: + bucket: 存储空间名称 + file_hash: 文件 MD5 哈希 + + Returns: + 文件信息或 None + """ + # 列举最近上传的 1000 个文件进行比对 + ret, eof, info = self.bucket.list(bucket, prefix=f"{self.PROJECT_PREFIX}/", limit=1000) + + if ret and "items" in ret: + for item in ret["items"]: + if item.get("hash") == file_hash: + return { + "key": item["key"], + "hash": item["hash"], + "fsize": item["fsize"], + "mime_type": item.get("mimeType", "application/octet-stream"), + } + return None + + def upload_file( + self, + local_path: str, + key: str = None, + file_type: str = "audio", + user_id: str = None, + check_duplicate: bool = True, + ) -> dict: + """ + 上传本地文件到七牛云 + + Args: + local_path: 本地文件路径 + key: 指定存储 Key(可选,不指定则自动生成) + file_type: 文件类型,用于自动生成 Key 和选择 bucket + user_id: 用户ID(可选) + check_duplicate: 是否检查重复文件(默认开启) + + Returns: + { + "key": 文件Key, + "hash": 文件哈希, + "url": 访问URL, + "mime_type": MIME类型, + "fsize": 文件大小(字节), + "is_duplicate": 是否复用已有文件 + } + """ + local_path = Path(local_path) + if not local_path.exists(): + raise FileNotFoundError(f"文件不存在: {local_path}") + + # 根据文件类型获取对应的 bucket 和 domain + bucket, domain = self._get_bucket_and_domain(file_type) + + # 计算文件 MD5 哈希 + file_md5 = self._calculate_file_hash(local_path) + + # 检查是否已存在相同文件 + if check_duplicate: + existing = self._find_file_by_hash(bucket, file_md5) + if existing: + return { + "key": existing["key"], + "hash": existing["hash"], + "url": self.get_file_url(domain, existing["key"]), + "mimeType": existing.get("mime_type", "application/octet-stream"), + "fsize": existing["fsize"], + "isDuplicate": True, + "message": "文件已存在,直接复用", + } + + # 自动生成 Key + if key is None: + key = self.generate_key(file_type, local_path.name, user_id) + + # 生成上传 Token + token = self.get_upload_token(bucket, key) + + # 使用分片上传 + ret, info = put_file(up_token=token, key=key, file_path=str(local_path)) + + if ret is None: + raise Exception(f"上传失败: {info}") + + # 获取文件信息 + mime_type, _ = mimetypes.guess_type(str(local_path)) + fsize = local_path.stat().st_size + + return { + "key": ret["key"], + "hash": ret["hash"], + "url": self.get_file_url(domain, key), + "mimeType": mime_type or "application/octet-stream", + "fsize": fsize, + "isDuplicate": False, + } + + def upload_stream( + self, stream: BinaryIO, key: str, mime_type: str = "application/octet-stream" + ) -> dict: + """ + 上传文件流到七牛云 + + Args: + stream: 文件流对象 + key: 文件存储 Key + mime_type: 文件 MIME 类型 + + Returns: + 上传结果字典 + """ + token = self.get_upload_token(key) + + ret, info = put_stream( + up_token=token, key=key, data_stream=stream, params=None, mime_type=mime_type + ) + + if ret is None: + raise Exception(f"上传失败: {info}") + + return {"key": ret["key"], "hash": ret["hash"], "url": self.get_file_url(key)} + + def upload_audio(self, local_path: str, user_id: str = None, key: str = None) -> dict: + """ + 上传音频文件(专用接口) + + Args: + local_path: 本地音频文件路径 + user_id: 用户ID(可选) + key: 指定 Key(可选) + + Returns: + 上传结果 + """ + # 验证文件类型 + mime_type, _ = mimetypes.guess_type(local_path) + if mime_type and not self.validate_file_type(mime_type, self.ALLOWED_AUDIO_TYPES): + raise ValueError(f"不支持的音频格式: {mime_type}") + + return self.upload_file(local_path=local_path, key=key, file_type="audio", user_id=user_id) + + def upload_video(self, local_path: str, user_id: str = None, key: str = None) -> dict: + """ + 上传视频文件(专用接口) + + Args: + local_path: 本地视频文件路径 + user_id: 用户ID(可选) + key: 指定 Key(可选) + + Returns: + 上传结果 + """ + # 验证文件类型 + mime_type, _ = mimetypes.guess_type(local_path) + if mime_type and not self.validate_file_type(mime_type, self.ALLOWED_VIDEO_TYPES): + raise ValueError(f"不支持的视频格式: {mime_type}") + + return self.upload_file(local_path=local_path, key=key, file_type="video", user_id=user_id) + + def upload_avatar_video( + self, + local_path: str, + user_id: str = None, + key: str = None, + file_hash: str = None, + ) -> dict: + """ + 上传形象克隆视频(专用接口) + + Args: + local_path: 本地视频文件路径 + user_id: 用户ID(可选) + key: 指定 Key(可选) + file_hash: 前端计算的文件SHA256哈希(可选,用于重复检测) + + Returns: + 上传结果,包含 isDuplicate(如果检测到七牛云上已有相同文件) + """ + # 验证文件类型 + mime_type, _ = mimetypes.guess_type(local_path) + if mime_type and not self.validate_file_type(mime_type, self.ALLOWED_VIDEO_TYPES): + raise ValueError(f"不支持的视频格式: {mime_type}") + + # 计算本地文件哈希用于七牛云重复检测 + local_file_hash = self._calculate_file_hash(Path(local_path)) + + # 检查七牛云上是否已有相同文件 + existing = self._find_file_by_hash(self.video_bucket, local_file_hash) + if existing: + logger.info(f"File already exists in Qiniu: {existing['key']}") + return { + "key": existing["key"], + "hash": existing["hash"], + "url": self.get_file_url(existing["key"]), + "mimeType": existing.get("mime_type", "video/mp4"), + "fsize": existing["fsize"], + "isDuplicate": True, + "message": "文件已存在,直接复用", + "existingTaskId": None, + } + + # 上传文件 + result = self.upload_file( + local_path=local_path, + key=key, + file_type="avatar", + user_id=user_id, + check_duplicate=False, # 已经在上面检查过了 + ) + + # 确保返回所有必需字段 + result["existingTaskId"] = None + return result + + def get_file_url(self, domain: str, key: str, expires: int = 0) -> str: + """ + 获取文件访问 URL + + Args: + domain: 加速域名 + key: 文件 Key + expires: 过期时间(秒),0 表示永久(公有空间) + + Returns: + 文件访问 URL + """ + base_url = f"https://{domain}/{key}" + + if expires > 0: + # 生成私有链接(临时 URL) + return self.auth.private_download_url(base_url, expires) + + return base_url + + def delete_file(self, bucket: str, key: str) -> bool: + """ + 删除文件 + + Args: + bucket: 存储空间名称 + key: 文件 Key + + Returns: + 是否删除成功 + """ + ret, info = self.bucket.delete(bucket, key) + return ret == {} + + def get_file_info(self, bucket: str, key: str) -> dict | None: + """ + 获取文件元信息 + + Args: + bucket: 存储空间名称 + key: 文件 Key + + Returns: + 文件信息字典,文件不存在返回 None + """ + ret, info = self.bucket.stat(bucket, key) + if ret is None: + return None + + # 根据 key 前缀推断文件类型,获取对应的 domain + file_type = "video" # 默认 + if "/images/" in key: + file_type = "image" + _, domain = self._get_bucket_and_domain(file_type) + + return { + "key": key, + "fsize": ret.get("fsize"), + "hash": ret.get("hash"), + "mime_type": ret.get("mimeType"), + "put_time": ret.get("putTime"), + "type": ret.get("type"), + "url": self.get_file_url(domain, key), + } + + def refresh_cdn(self, keys: list[str]) -> dict: + """ + 刷新 CDN 缓存 + + Args: + keys: 文件 Key 列表 + + Returns: + 刷新结果 + """ + urls = [] + for key in keys: + # 根据 key 推断文件类型获取 domain + file_type = "video" + if "/images/" in key: + file_type = "image" + _, domain = self._get_bucket_and_domain(file_type) + urls.append(self.get_file_url(domain, key)) + + ret, info = self.cdn.refresh_urls(urls) + return { + "code": ret.get("code"), + "request_id": info.req_id if hasattr(info, "req_id") else None, + } + + +# 全局单例 +_qiniu_service: QiniuService | None = None + + +def get_qiniu_service() -> QiniuService: + """获取 QiniuService 单例""" + global _qiniu_service + if _qiniu_service is None: + _qiniu_service = QiniuService() + return _qiniu_service diff --git a/python-api/app/services/script_service.py b/python-api/app/services/script_service.py new file mode 100644 index 0000000..758cc05 --- /dev/null +++ b/python-api/app/services/script_service.py @@ -0,0 +1,662 @@ +""" +脚本生成服务 +============ +""" + +import asyncio +import logging +import math +import re +import time +from collections.abc import AsyncIterator +from pathlib import Path + +from app.ai.model_router import get_model_router +from app.ai.prompts import load_script_system, load_script_user +from app.schemas.script import ScriptGenerationEvent, ScriptShot +from app.services.ai_response_utils import ( + safe_parse_ai_json_response, + validate_and_normalize_shots, +) +from app.services.anytocopy_service import ( + AnyToCopyService, + get_anytocopy_service, +) + +logger = logging.getLogger(__name__) + + +class ScriptService: + """脚本生成服务""" + + # 根据视频时长估算输出字符数(经验值) + # 格式: {时长: (最小字符数, 最大字符数)} + DURATION_ESTIMATES = { + 30: (800, 1200), + 45: (1200, 1600), + 60: (1500, 2000), + 90: (2000, 2800), + } + + def __init__(self): + self.prompts_dir = Path(__file__).parent.parent / "ai" / "prompts" + + def _estimate_total_chars(self, duration: int) -> int: + """ + 根据时长估算总输出字符数 + + Args: + duration: 视频时长(秒) + + Returns: + 预估字符数 + """ + # 找到最接近的预设 + closest_duration = min(self.DURATION_ESTIMATES.keys(), key=lambda x: abs(x - duration)) + min_chars, max_chars = self.DURATION_ESTIMATES[closest_duration] + + # 根据实际时长在区间内插值 + ratio = duration / closest_duration + estimated = int(min_chars + (max_chars - min_chars) * ratio) + + logger.debug(f"时长 {duration}s 预估字符数: {estimated}") + return estimated + + def _calculate_progress( + self, + current_chars: int, + estimated_total: int, + elapsed_time: float, + min_expected_time: float = 5.0, + ) -> int: + """ + 计算平滑进度(使用对数曲线)- 优化版,避免抖动 + + 设计思路: + - 主要基于内容生成进度,时间只作为保底 + - 使用单调递增函数,确保进度只增不减 + - 前 20% 内容:进度到 30%(慢启动) + - 中间 60% 内容:进度到 75%(稳定生成期) + - 最后 20% 内容:进度到 85%(收尾阶段) + + Args: + current_chars: 当前字符数 + estimated_total: 预估总字符数 + elapsed_time: 已过去时间(秒) + min_expected_time: 最少预期的生成时间(避免太快跑完) + + Returns: + 进度百分比 (0-85) + """ + # 基于内容的进度(对数曲线) + ratio = min(current_chars / estimated_total, 1.5) # 允许超出生成 50% + + # 对数曲线:前期慢,后期快 + if ratio <= 1.0: + # 未完成或刚好完成:使用调整后的对数曲线,最高到 85% + progress_ratio = math.log(1 + ratio * 2) / math.log(3) * 0.85 + else: + # 已超出生成:从 85% 线性增长,最多到 95%(预留空间给后续阶段) + progress_ratio = 0.85 + min((ratio - 1) * 0.2, 0.1) + + # 基于时间的保底进度(只在内容很少时生效,避免生成太快时进度条没动) + # 使用平滑的保底函数,只在前期(前3秒)和内容很少时生效 + time_progress = 0 + if current_chars < estimated_total * 0.3 and elapsed_time < min_expected_time: + # 内容生成少于30%且时间少于5秒时,提供保底进度 + time_progress = min(elapsed_time / min_expected_time * 0.15, 0.15) + + # 取较大值,但主要依赖 progress_ratio,time_progress 只作为早期保底 + final_ratio = max(progress_ratio, time_progress) + + # 生成阶段最高到 85% + return min(int(final_ratio * 100), 85) + + def _load_prompt(self, name: str) -> str: + """加载 Prompt 模板""" + prompt_file = self.prompts_dir / f"{name}.txt" + if prompt_file.exists(): + return prompt_file.read_text(encoding="utf-8") + return "" + + @staticmethod + def _extract_json(content: str) -> str: + """ + 从 Markdown 代码块中提取 JSON,或返回原始内容 + + 支持格式: + - ```json {...} ``` + - ``` {...} ``` + - 纯 JSON 文本 + """ + if not content: + return "" + + content = content.strip() + + # 匹配 ```json ... ``` 或 ``` ... ``` + pattern = r"```(?:json)?\s*([\s\S]*?)\s*```" + matches = re.findall(pattern, content) + + if matches: + # 取最后一个匹配(避免前面有示例代码) + return matches[-1].strip() + + # 如果没有代码块,返回原始内容 + return content + + async def generate_script( + self, + topic: str, + duration: int, + script_type: str, + model: str | None = None, + ) -> list[ScriptShot]: + """ + 同步生成脚本 + + Args: + topic: 创作主题(支持视频链接,自动提取文案) + duration: 视频时长(秒) + script_type: 脚本类型 + model: 指定模型 + + Returns: + 分镜列表 + """ + # 1. 检测并提取视频链接中的文案 + anytocopy = get_anytocopy_service() + extract_result = await anytocopy.extract_text_from_input(topic) + + if extract_result["error"]: + logger.warning(f"视频文案提取失败: {extract_result['error']}") + # 提取失败但不中断,使用原始输入 + + if extract_result["is_video_url"]: + logger.info(f"检测到视频链接,提取文案长度: {len(extract_result['extracted_text'])}") + # 使用提取的文案作为创作主题 + topic = extract_result["extracted_text"] or topic + + # 2. 获取 model_router + model_router = await get_model_router() + + # 加载 Prompt(使用新的 loader) + system_prompt = load_script_system() + user_prompt = load_script_user( + topic=topic, + duration=duration, + script_type=script_type, + ) + + logger.info(f"同步生成脚本: topic={topic[:20]}, duration={duration}") + + # 调用 AI 生成 + result = await model_router.generate( + prompt=user_prompt, + system_prompt=system_prompt, + model_id=model, + task_type="script", + temperature=0.7, + ) + + # 检查返回内容 + if not result.content or not result.content.strip(): + logger.error("AI 返回内容为空") + raise ValueError("AI 返回内容为空,请检查模型配置或重试") + + logger.info(f"AI 返回内容长度: {len(result.content)} 字符") + + # 使用安全的 JSON 解析 + success, parsed_data, error_msg = safe_parse_ai_json_response(result.content) + + if not success: + logger.error(f"JSON 解析失败: {error_msg}") + logger.error(f"原始内容: {result.content[:500]!r}") + raise ValueError(error_msg or "AI 返回格式错误,无法解析为 JSON") + + # 验证并标准化分镜数据 + try: + shots_data = validate_and_normalize_shots(parsed_data) + + if not shots_data: + raise ValueError("AI 返回的分镜数据为空或格式不正确") + + # 转换为 ScriptShot 对象 + shots = [ScriptShot(**shot) for shot in shots_data] + logger.info(f"成功解析 {len(shots)} 个分镜") + return shots + + except Exception as e: + logger.error(f"分镜数据标准化失败: {e}") + raise ValueError(f"分镜数据处理失败: {str(e)}") + + async def generate_script_stream( + self, + topic: str, + duration: int, + script_type: str, + model: str | None = None, + ) -> AsyncIterator[ScriptGenerationEvent]: + """ + 流式生成脚本(SSE)- 优化版 + + 支持视频链接自动提取文案。 + + 进度设计: + - 0-5%: start(初始化) + - 5-15%: analyzing(分析主题,含视频文案提取) + - 15-85%: generating(AI 生成,平滑对数曲线增长) + - 85-92%: validating(JSON 验证) + - 92-98%: parsing(解析分镜) + - 98-100%: complete(完成) + """ + model_router = await get_model_router() + start_time = time.time() + + # 1. 检测并提取视频链接中的文案 + original_topic = topic + anytocopy = get_anytocopy_service() + extracted_info = None # 保存提取的视频信息 + + # 检查是否为视频链接 + if AnyToCopyService.is_video_url(topic) or AnyToCopyService.extract_url_from_text(topic): + yield ScriptGenerationEvent( + type="analyzing", + progress=5, + message="检测到视频链接,正在提取文案...", + ) + + extract_result = await anytocopy.extract_text_from_input(topic) + + if extract_result["error"]: + logger.warning(f"视频文案提取失败: {extract_result['error']}") + yield ScriptGenerationEvent( + type="analyzing", + progress=8, + message="视频文案提取失败,使用原始输入继续生成...", + ) + elif extract_result["is_video_url"]: + extracted_text = extract_result["extracted_text"] + logger.info(f"视频文案提取成功,长度: {len(extracted_text)}") + topic = extracted_text or topic + + # 保存提取的视频信息(只要有 video_info 就返回) + video_info = extract_result.get("video_info") + if video_info: + extracted_info = { + "title": video_info.title, + "content": video_info.content, + "text_content": video_info.text_content, + "platform": video_info.platform, + "duration": video_info.duration, + "original_url": original_topic, + } + + yield ScriptGenerationEvent( + type="analyzing", + progress=10, + message=f"视频文案提取成功,共 {len(extracted_text)} 字符", + ) + + try: + # 加载 Prompt + system_prompt = load_script_system() + user_prompt = load_script_user( + topic=topic, + duration=duration, + script_type=script_type, + ) + + # 1. 开始阶段(0-5%) + yield ScriptGenerationEvent( + type="start", + progress=2, + message="准备生成脚本...", + ) + + # 2. 分析阶段(5-15%) + yield ScriptGenerationEvent( + type="analyzing", + progress=10, + message="分析创作要点", + ) + + # 估算总长度(根据时长) + estimated_total = self._estimate_total_chars(duration) + + # 3. 生成阶段(15-55%)- 降低占比,给后续步骤留更多空间 + yield ScriptGenerationEvent( + type="generating", + progress=15, + message="正在创作脚本...", + ) + + full_content = "" + last_progress = 15 + last_update_time = start_time + update_interval = 0.5 # 最少 500ms 更新一次 + chunk_count = 0 + + logger.info(f"开始流式生成: topic={topic[:20]}, duration={duration}") + + async for chunk in model_router.generate_stream_with_progress( + prompt=user_prompt, + system_prompt=system_prompt, + model_id=model, + task_type="script", + temperature=0.7, + ): + chunk_count += 1 + + if chunk["type"] == "chunk": + chunk_content = chunk.get("content", "") + if not chunk_content: + logger.warning(f"收到空 chunk,序号: {chunk_count}") + continue + + full_content += chunk_content + current_chars = len(full_content) + elapsed = time.time() - start_time + + # 计算平滑进度(对数曲线,最高到55%) + base_progress = self._calculate_progress( + current_chars=current_chars, + estimated_total=estimated_total, + elapsed_time=elapsed, + ) + # 将原来的 15-85 映射到 15-55 + progress = 15 + int((base_progress - 15) * 40 / 70) + + # 限制更新频率,但确保每次有变化都上报(最小 2% 变化) + current_time = time.time() + if progress > last_progress and ( + progress - last_progress >= 2 + or current_time - last_update_time >= update_interval + ): + + yield ScriptGenerationEvent( + type="generating", + progress=progress, + message="正在创作脚本...", + ) + last_progress = progress + last_update_time = current_time + + elif chunk["type"] == "usage": + prompt_tokens = chunk.get("prompt_tokens", 0) + completion_tokens = chunk.get("completion_tokens", 0) + logger.info( + f"Token 使用: prompt={prompt_tokens}, completion={completion_tokens}" + ) + + logger.info(f"流式生成结束: 共 {chunk_count} 个 chunk, {len(full_content)} 字符") + + # 4. 验证阶段(55-70%) + actual_chars = len(full_content) + logger.info(f"生成完成: {actual_chars} 字符 (预估: {estimated_total})") + + yield ScriptGenerationEvent( + type="validating", + progress=60, + message="验证脚本格式...", + ) + + await asyncio.sleep(0.5) + + yield ScriptGenerationEvent( + type="validating", + progress=65, + message="检查数据完整性...", + ) + + await asyncio.sleep(0.5) + + yield ScriptGenerationEvent( + type="validating", + progress=70, + message="验证通过", + ) + + await asyncio.sleep(0.5) + + # 5. 解析阶段(70-80%) + yield ScriptGenerationEvent( + type="parsing", + progress=75, + message="解析分镜内容...", + ) + + # 检查内容是否为空 + if not full_content or not full_content.strip(): + logger.error("AI 返回内容为空") + yield ScriptGenerationEvent( + type="error", + progress=0, + message="AI 返回内容为空,请检查模型配置或重试", + ) + return + + # 记录原始内容(调试用) + logger.info(f"AI 原始输出: {full_content[:500]}...") + + # 使用安全的 JSON 解析 + success, parsed_data, error_msg = safe_parse_ai_json_response(full_content) + + if not success: + logger.error(f"JSON 解析失败: {error_msg}") + logger.error(f"原始内容前500字符: {full_content[:500]!r}") + + # 给前端更详细的错误信息 + error_detail = error_msg or "无法解析 AI 返回的内容" + if not full_content or not full_content.strip(): + error_detail = "AI 返回内容为空,请检查模型配置或重试" + + yield ScriptGenerationEvent( + type="error", + progress=0, + message=f"脚本解析失败: {error_detail}", + ) + return + + # 验证并标准化分镜数据 + try: + shots_data = validate_and_normalize_shots(parsed_data) + + if not shots_data: + logger.error("标准化后分镜列表为空") + yield ScriptGenerationEvent( + type="error", + progress=0, + message="AI 返回的分镜数据为空或格式不正确", + ) + return + + # 转换为 ScriptShot 对象 + shots = [ScriptShot(**shot) for shot in shots_data] + + # 6. 完成阶段(80-100%)- 细分为多个步骤,让用户感知进度 + yield ScriptGenerationEvent( + type="finalizing", + progress=80, + message=f"整理 {len(shots)} 个分镜...", + ) + + await asyncio.sleep(0.5) + + yield ScriptGenerationEvent( + type="finalizing", + progress=85, + message="优化镜头顺序...", + ) + + await asyncio.sleep(0.5) + + yield ScriptGenerationEvent( + type="finalizing", + progress=90, + message="检查时长分配...", + ) + + await asyncio.sleep(0.5) + + yield ScriptGenerationEvent( + type="finalizing", + progress=95, + message="准备完成...", + ) + + await asyncio.sleep(0.5) + + yield ScriptGenerationEvent( + type="complete", + progress=100, + message=f"成功生成 {len(shots)} 个分镜", + result=shots, + extracted_info=extracted_info, + ) + + except Exception as e: + logger.error(f"分镜数据标准化失败: {e}") + yield ScriptGenerationEvent( + type="error", + progress=0, + message=f"分镜数据处理失败: {str(e)}", + ) + + except Exception as e: + logger.exception("脚本生成失败") + yield ScriptGenerationEvent( + type="error", + progress=0, + message=f"生成失败: {str(e)}", + ) + + async def polish_content( + self, + content: str, + polish_type: str = "voiceover", + shot_type: str = "segment", + ) -> str: + """ + 润色内容 + + Args: + content: 待润色内容 + polish_type: 润色类型,可选 "scene"(画面描述)或 "voiceover"(配音文案) + shot_type: 镜头类型,可选 "segment"(分镜)或 "empty_shot"(空镜),仅用于画面润色 + + Returns: + 润色后的内容 + """ + # 获取 model_router + model_router = await get_model_router() + + # 从文件加载提示词模板 + if polish_type == "scene": + # 画面润色需要根据镜头类型选择不同提示词 + if shot_type == "empty_shot": + prompt_template = self._load_prompt("polish/scene_empty_shot") + else: + prompt_template = self._load_prompt("polish/scene_segment") + + # 如果特定类型的提示词不存在,回退到通用 scene 提示词 + if not prompt_template: + prompt_template = self._load_prompt("polish/scene") + else: + # 配音文案润色 + prompt_template = self._load_prompt("polish/voiceover") + + if not prompt_template: + # 最终回退 + prompt_template = "请润色以下内容:\n\n{content}" + + prompt = prompt_template.format(content=content) + + result = await model_router.generate( + prompt=prompt, + task_type="polish", + temperature=0.5, + max_tokens=300, + ) + + return result.content.strip() + + async def check_model_health(self) -> dict: + """检查模型健康状态""" + model_router = await get_model_router() + health_results = await model_router.health_check() + + models = [] + available_count = 0 + recommended = None + + for provider_id, health in health_results.items(): + model_info = { + "id": health.id, + "name": health.name, + "is_available": health.is_available, + "response_time": health.response_time, + "last_error": health.last_error, + } + models.append(model_info) + + if health.is_available: + available_count += 1 + if recommended is None or health.response_time < recommended.get( + "response_time", float("inf") + ): + recommended = model_info + + total = len(models) + + return { + "status": "healthy" if available_count > 0 else "unhealthy", + "models": models, + "recommended_model": recommended, + "total_models": total, + "available_models": available_count, + } + + async def test_model(self, model_id: str | None = None) -> dict: + """测试指定模型连接""" + model_router = await get_model_router() + + import time + + start_time = time.time() + + try: + result = await model_router.generate( + prompt="你好", + model_id=model_id, + max_tokens=5, + ) + + response_time = (time.time() - start_time) * 1000 + + return { + "success": True, + "model": result.model, + "response_time": round(response_time, 2), + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + + except Exception as e: + return { + "success": False, + "model": model_id or "default", + "error": str(e), + "checked_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + + +# 全局单例 +_script_service: ScriptService | None = None + + +def get_script_service() -> ScriptService: + """获取 ScriptService 单例""" + global _script_service + if _script_service is None: + _script_service = ScriptService() + return _script_service diff --git a/python-api/app/services/volcengine_caption_service.py b/python-api/app/services/volcengine_caption_service.py new file mode 100644 index 0000000..44af413 --- /dev/null +++ b/python-api/app/services/volcengine_caption_service.py @@ -0,0 +1,566 @@ +""" +火山引擎音视频字幕服务 +====================== + +基于火山引擎 OpenSpeech API 的音视频字幕生成服务。 + +文档: https://www.volcengine.com/docs/6561/80907 +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +import httpx + +from app.config import get_settings +from app.schemas.caption import ( + AutoAlignResult, + CaptionResult, + CaptionUtterance, + CaptionWord, +) + +logger = logging.getLogger(__name__) + + +class VolcengineCaptionError(Exception): + """火山引擎字幕服务异常""" + + def __init__(self, message: str, code: int = None, original_error: Exception = None): + super().__init__(message) + self.code = code + self.original_error = original_error + + +class VolcengineCaptionService: + """ + 火山引擎音视频字幕服务封装 + """ + + # API 基础配置 + BASE_URL = "https://openspeech.bytedance.com/api/v1/vc" + DEFAULT_TIMEOUT = 60.0 + DEFAULT_POLL_INTERVAL = 1.0 + MAX_POLL_RETRIES = 120 # 最多轮询120秒 + + # 错误码映射 + ERROR_CODES = { + 0: "成功", + 2000: "处理中", + 1001: "参数无效", + 1002: "无权限", + 1003: "超频", + 1010: "音频过长", + 1011: "音频过大", + 1012: "格式无效", + 1013: "音频静音", + } + + def __init__(self, appid: str | None = None, token: str | None = None): + """ + 初始化字幕服务 + + Args: + appid: 应用ID,默认从 Settings 读取 + token: 鉴权Token,默认从 Settings 读取 + """ + settings = get_settings() + self.appid = appid or settings.VOLCENGINE_CAPTION_APPID or "" + self.token = token or settings.VOLCENGINE_CAPTION_TOKEN or "" + + if not self.appid: + raise VolcengineCaptionError("VOLCENGINE_CAPTION_APPID 未配置") + if not self.token: + raise VolcengineCaptionError("VOLCENGINE_CAPTION_TOKEN 未配置") + + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + """获取 HTTP 客户端""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=self.DEFAULT_TIMEOUT) + return self._client + + def _get_headers(self) -> dict: + """获取请求头""" + return { + "Authorization": f"Bearer; {self.token}", + "Content-Type": "application/json", + } + + async def submit_caption_task( + self, + audio_url: str, + language: str = "zh-CN", + caption_type: str = "auto", + use_punc: bool = True, + use_itn: bool = True, + words_per_line: int = 46, + max_lines: int = 1, + ) -> str: + """ + 提交字幕生成任务 + + Args: + audio_url: 音频/视频文件URL + language: 语言代码 + caption_type: 识别类型 (auto/speech/singing) + use_punc: 自动标点 + use_itn: 数字转换 + words_per_line: 每行字数 + max_lines: 每屏行数 + + Returns: + 任务ID + + Raises: + VolcengineCaptionError: 提交失败 + """ + client = await self._get_client() + + params = { + "appid": self.appid, + "language": language, + "caption_type": caption_type, + "use_punc": str(use_punc), + "use_itn": str(use_itn), + "words_per_line": words_per_line, + "max_lines": max_lines, + } + + payload = {"url": audio_url} + + try: + response = await client.post( + f"{self.BASE_URL}/submit", + params=params, + json=payload, + headers=self._get_headers(), + ) + response.raise_for_status() + data = response.json() + + if "id" not in data: + raise VolcengineCaptionError(f"提交任务失败: {data.get('message', '未知错误')}") + + task_id = data["id"] + logger.info(f"字幕任务已提交: {task_id}") + return task_id + + except httpx.HTTPStatusError as e: + raise VolcengineCaptionError( + f"HTTP错误: {e.response.status_code}", + original_error=e, + ) + except Exception as e: + raise VolcengineCaptionError(f"提交任务失败: {str(e)}", original_error=e) + + async def query_caption_task( + self, + task_id: str, + blocking: bool = False, + ) -> CaptionResult: + """ + 查询字幕任务结果 + + Args: + task_id: 任务ID + blocking: 是否阻塞等待结果 (blocking=1) + + Returns: + 字幕结果 + + Raises: + VolcengineCaptionError: 查询失败 + """ + client = await self._get_client() + + params = { + "appid": self.appid, + "id": task_id, + "blocking": 1 if blocking else 0, + } + + try: + response = await client.get( + f"{self.BASE_URL}/query", + params=params, + headers=self._get_headers(), + ) + response.raise_for_status() + data = response.json() + + return self._parse_caption_result(data) + + except httpx.HTTPStatusError as e: + raise VolcengineCaptionError( + f"HTTP错误: {e.response.status_code}", + original_error=e, + ) + except Exception as e: + raise VolcengineCaptionError(f"查询任务失败: {str(e)}", original_error=e) + + async def generate_caption( + self, + audio_url: str, + language: str = "zh-CN", + caption_type: str = "auto", + use_punc: bool = True, + use_itn: bool = True, + words_per_line: int = 46, + max_lines: int = 1, + max_wait_time: int = 120, + ) -> CaptionResult: + """ + 生成字幕(完整流程:提交->轮询->返回结果) + + Args: + audio_url: 音频/视频文件URL + language: 语言代码 + caption_type: 识别类型 + use_punc: 自动标点 + use_itn: 数字转换 + words_per_line: 每行字数 + max_lines: 每屏行数 + max_wait_time: 最大等待时间(秒) + + Returns: + 字幕生成结果 + + Raises: + VolcengineCaptionError: 生成失败或超时 + """ + # 提交任务 + task_id = await self.submit_caption_task( + audio_url=audio_url, + language=language, + caption_type=caption_type, + use_punc=use_punc, + use_itn=use_itn, + words_per_line=words_per_line, + max_lines=max_lines, + ) + + # 轮询结果 + start_time = asyncio.get_event_loop().time() + retries = 0 + + while retries < self.MAX_POLL_RETRIES: + result = await self.query_caption_task(task_id, blocking=True) + + if result.code == 0: + logger.info(f"字幕生成完成: {task_id}, 时长: {result.duration}s") + return result + elif result.code == 2000: + # 仍在处理中 + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed > max_wait_time: + raise VolcengineCaptionError(f"字幕生成超时: 已等待 {max_wait_time}s") + await asyncio.sleep(self.DEFAULT_POLL_INTERVAL) + retries += 1 + else: + # 其他错误 + error_msg = self.ERROR_CODES.get(result.code, f"未知错误: {result.code}") + raise VolcengineCaptionError( + f"字幕生成失败: {error_msg} ({result.message})", code=result.code + ) + + raise VolcengineCaptionError("字幕生成超时: 超过最大重试次数") + + async def submit_auto_align_task( + self, + audio_url: str, + audio_text: str, + caption_type: str = "speech", + sta_punc_mode: int = 3, + ) -> str: + """ + 提交自动字幕打轴任务 + + Args: + audio_url: 音频/视频文件URL + audio_text: 要打轴的字幕文本 + caption_type: 识别类型 (speech/singing) + sta_punc_mode: 标点模式 (1/2/3) + + Returns: + 任务ID + """ + client = await self._get_client() + + params = { + "appid": self.appid, + "caption_type": caption_type, + "sta_punc_mode": sta_punc_mode, + } + + payload = { + "url": audio_url, + "audio_text": audio_text, + } + + try: + response = await client.post( + f"{self.BASE_URL}/ata/submit", + params=params, + json=payload, + headers=self._get_headers(), + ) + response.raise_for_status() + data = response.json() + + if "id" not in data: + raise VolcengineCaptionError(f"提交打轴任务失败: {data.get('message', '未知错误')}") + + task_id = data["id"] + logger.info(f"打轴任务已提交: {task_id}") + return task_id + + except Exception as e: + raise VolcengineCaptionError(f"提交打轴任务失败: {str(e)}", original_error=e) + + async def query_auto_align_task( + self, + task_id: str, + blocking: bool = False, + ) -> AutoAlignResult: + """ + 查询打轴任务结果 + + Args: + task_id: 任务ID + blocking: 是否阻塞等待 + + Returns: + 打轴结果 + """ + client = await self._get_client() + + params = { + "appid": self.appid, + "id": task_id, + "blocking": 1 if blocking else 0, + } + + try: + response = await client.get( + f"{self.BASE_URL}/ata/query", + params=params, + headers=self._get_headers(), + ) + response.raise_for_status() + data = response.json() + logger.info( + f"[VolcengineCaption] Query response: {json.dumps(data, ensure_ascii=False)}" + ) + + # 解析结果(与字幕生成结果格式相同) + caption_result = self._parse_caption_result(data) + logger.info(f"[VolcengineCaption] Parsed result: {caption_result}") + logger.info( + f"[VolcengineCaption] First utterance: {caption_result.utterances[0] if caption_result.utterances else None}" + ) + return AutoAlignResult( + code=caption_result.code, + message=caption_result.message, + duration=caption_result.duration, + utterances=caption_result.utterances, + ) + + except Exception as e: + raise VolcengineCaptionError(f"查询打轴任务失败: {str(e)}", original_error=e) + + async def auto_align_caption( + self, + audio_url: str, + audio_text: str, + caption_type: str = "speech", + sta_punc_mode: int = 3, + max_wait_time: int = 120, + ) -> AutoAlignResult: + """ + 自动字幕打轴(完整流程) + + Args: + audio_url: 音频/视频文件URL + audio_text: 要打轴的字幕文本 + caption_type: 识别类型 + sta_punc_mode: 标点模式 + max_wait_time: 最大等待时间 + + Returns: + 打轴结果 + """ + task_id = await self.submit_auto_align_task( + audio_url=audio_url, + audio_text=audio_text, + caption_type=caption_type, + sta_punc_mode=sta_punc_mode, + ) + + start_time = asyncio.get_event_loop().time() + retries = 0 + + while retries < self.MAX_POLL_RETRIES: + result = await self.query_auto_align_task(task_id, blocking=True) + + if result.code == 0: + logger.info(f"打轴完成: {task_id}") + return result + elif result.code == 2000: + elapsed = asyncio.get_event_loop().time() - start_time + if elapsed > max_wait_time: + raise VolcengineCaptionError(f"打轴超时: 已等待 {max_wait_time}s") + await asyncio.sleep(self.DEFAULT_POLL_INTERVAL) + retries += 1 + else: + error_msg = self.ERROR_CODES.get(result.code, f"未知错误: {result.code}") + raise VolcengineCaptionError( + f"打轴失败: {error_msg} ({result.message})", code=result.code + ) + + raise VolcengineCaptionError("打轴超时: 超过最大重试次数") + + def _parse_caption_result(self, data: dict) -> CaptionResult: + """解析 API 响应为 CaptionResult""" + utterances = [] + logger.info(f"[VolcengineCaption] Parsing caption result: {data}") + + for u in data.get("utterances", []): + logger.info(f"[VolcengineCaption] Parsing utterance: {u}") + # 火山引擎可能返回驼峰命名或下划线命名的字段 + words = [ + CaptionWord( + text=w.get("text", ""), + start_time=w.get("start_time", 0) or w.get("startTime", 0), + end_time=w.get("end_time", 0) or w.get("endTime", 0), + ) + for w in u.get("words", []) + ] + + utterances.append( + CaptionUtterance( + text=u.get("text", ""), + start_time=u.get("start_time", 0) or u.get("startTime", 0), + end_time=u.get("end_time", 0) or u.get("endTime", 0), + words=words, + ) + ) + + result = CaptionResult( + code=data.get("code", -1), + message=data.get("message", ""), + duration=data.get("duration", 0.0), + utterances=utterances, + ) + logger.info(f"[VolcengineCaption] Parsed result: {result}") + return result + + @staticmethod + def to_srt(utterances: list[CaptionUtterance]) -> str: + """ + 将字幕时间轴转换为 SRT 格式 + + Args: + utterances: 字幕时间轴列表 + + Returns: + SRT 格式字符串 + """ + + def ms_to_time(ms: int) -> str: + """毫秒转换为 SRT 时间格式 HH:MM:SS,mmm""" + h = ms // 3600000 + m = (ms % 3600000) // 60000 + s = (ms % 60000) // 1000 + ms_remain = ms % 1000 + return f"{h:02d}:{m:02d}:{s:02d},{ms_remain:03d}" + + lines = [] + for i, u in enumerate(utterances, 1): + lines.append(str(i)) + lines.append(f"{ms_to_time(u.start_time)} --> {ms_to_time(u.end_time)}") + lines.append(u.text) + lines.append("") + + return "\n".join(lines).strip() + + @staticmethod + def to_ass( + utterances: list[CaptionUtterance], + video_width: int = 1080, + video_height: int = 1920, + ) -> str: + """ + 将字幕转换为 ASS 格式(使用抖音美好体) + + Args: + utterances: 字幕时间轴 + video_width: 视频宽度 + video_height: 视频高度 + + Returns: + ASS 格式字符串 + """ + from app.services.ass_generator import generate_ass + + return generate_ass( + utterances=utterances, + video_width=video_width, + video_height=video_height, + ) + + @staticmethod + def to_vtt(utterances: list[CaptionUtterance]) -> str: + """ + 将字幕时间轴转换为 WebVTT 格式 + + Args: + utterances: 字幕时间轴列表 + + Returns: + WebVTT 格式字符串 + """ + + def ms_to_vtt_time(ms: int) -> str: + """毫秒转换为 VTT 时间格式 HH:MM:SS.mmm""" + h = ms // 3600000 + m = (ms % 3600000) // 60000 + s = (ms % 60000) // 1000 + ms_remain = ms % 1000 + return f"{h:02d}:{m:02d}:{s:02d}.{ms_remain:03d}" + + lines = ["WEBVTT", ""] + + for u in utterances: + lines.append(f"{ms_to_vtt_time(u.start_time)} --> {ms_to_vtt_time(u.end_time)}") + lines.append(u.text) + lines.append("") + + return "\n".join(lines).strip() + + async def close(self): + """关闭 HTTP 客户端""" + if self._client and not self._client.is_closed: + await self._client.aclose() + + +# 全局服务单例 +_caption_service: VolcengineCaptionService | None = None + + +async def get_caption_service() -> VolcengineCaptionService: + """获取字幕服务单例""" + global _caption_service + if _caption_service is None: + _caption_service = VolcengineCaptionService() + return _caption_service + + +def reset_caption_service(): + """重置字幕服务单例(用于测试)""" + global _caption_service + _caption_service = None diff --git a/python-api/check_all_shots.py b/python-api/check_all_shots.py new file mode 100644 index 0000000..77191bc --- /dev/null +++ b/python-api/check_all_shots.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +批量查询所有8个分镜在KlingAI的状态 +""" + +import asyncio +from app.ai.providers.klingai_provider import KlingAIProvider +from app.config import get_settings + +settings = get_settings() + +provider = KlingAIProvider({ + "access_key": settings.KLINGAI_ACCESS_KEY or "", + "secret_key": settings.KLINGAI_SECRET_KEY or "", + "base_url": "https://api-beijing.klingai.com", +}) + +# 8个任务ID +task_ids = [ + "875103915997044747", + "875103917494398981", + "875103918991761427", + "875103920820342867", + "875103922871357495", + "875103924817661991", + "875103926306492426", + "875103927824965644", +] + +async def check_all(): + for i, task_id in enumerate(task_ids): + print(f"\n=== 分镜 {i+1} - task_id={task_id} ===") + result = await provider.get_omni_video_task(task_id) + print(f"task_status: {result.get('task_status')}") + print(f"task_status_msg: {result.get('task_status_msg', '')}") + print(f"created_at: {result.get('created_at')}") + print(f"updated_at: {result.get('updated_at')}") + +if __name__ == "__main__": + asyncio.run(check_all()) diff --git a/python-api/check_task_status.py b/python-api/check_task_status.py new file mode 100644 index 0000000..11b14a2 --- /dev/null +++ b/python-api/check_task_status.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +""" +手动查询卡住的任务状态 +""" + +import asyncio +from app.ai.providers.klingai_provider import KlingAIProvider +from app.config import get_settings + +settings = get_settings() + +provider = KlingAIProvider({ + "access_key": settings.KLINGAI_ACCESS_KEY or "", + "secret_key": settings.KLINGAI_SECRET_KEY or "", + "base_url": "https://api-beijing.klingai.com", +}) + +task_id = "875095792892530770" # 卡住的第3个任务 + +async def check(): + print(f"查询任务状态: {task_id}") + result = await provider.get_omni_video_task(task_id) + print(f"返回结果:") + import json + print(json.dumps(result, indent=2, ensure_ascii=False)) + +if __name__ == "__main__": + asyncio.run(check()) diff --git a/python-api/config/ai_models.yaml b/python-api/config/ai_models.yaml new file mode 100644 index 0000000..d112a91 --- /dev/null +++ b/python-api/config/ai_models.yaml @@ -0,0 +1,239 @@ +# AI 模型配置文件 +# ================= +# 配置各平台及其可用模型 +# 修改后可通过 API 热重载,无需重启服务 +# +# ⚠️ 重要说明: +# API Key 不再在此文件配置!请通过 .env 文件设置: +# - 火山方舟:VOLCENGINE_API_KEY +# - 可灵 AI:KLINGAI_ACCESS_KEY, KLINGAI_SECRET_KEY +# +# 使用方法: +# 1. 在火山方舟控制台开通模型服务:https://console.volcengine.com/ark/ +# 2. 获取 API Key 并设置到 .env 文件:VOLCENGINE_API_KEY=your-api-key +# 3. 在模型广场开通需要的模型,复制 Model ID 填入下方配置 +# +# Model ID 格式:{模型名称}-{版本日期} +# 示例:doubao-seed-2-0-lite-260215 +# +# 官方文档:https://www.volcengine.com/docs/82379/2123245 + +# 平台配置 +platforms: + # Mock 平台(用于测试) + mock: + name: "Mock 测试平台" + provider: "mock" + priority: 999 + + # 火山方舟(字节跳动) + volcengine: + name: "火山方舟" + provider: "volcengine" + priority: 5 + # API Key 从 Settings (.env) 读取:VOLCENGINE_API_KEY + # 可选节点: + # 北京: https://ark.cn-beijing.volces.com/api/v3 (默认) + # 上海: https://ark.cn-shanghai.volces.com/api/v3 + # 广州: https://ark.cn-guangzhou.volces.com/api/v3 + base_url: "https://ark.cn-beijing.volces.com/api/v3" + + # 可灵 AI(快手) + klingai: + name: "可灵 AI" + provider: "klingai" + priority: 10 + # Access Key 和 Secret Key 从 Settings (.env) 读取: + # KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY + # 注意:新系统调用域名已变更为 https://api-beijing.klingai.com + base_url: "https://api-beijing.klingai.com" + +# 模型配置 +# model_name: 填写 Model ID,格式:{模型名称}-{版本日期} +# capabilities: 能力标签 [script, polish, chat, image, embedding, vision] +models: + # Mock 模型 + mock-model: + platform_id: "mock" + model_name: "mock-model" + display_name: "Mock 测试模型" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 2000 + is_enabled: true + + # ===== 火山方舟模型 ===== + # 使用方式: + # 1. 前往方舟控制台 → 模型广场 + # 2. 找到需要的模型,点击"开通" + # 3. 复制 Model ID 填入下方的 model_name + + # 脚本生成、润色 - 高性能模型(质量优先) + doubao-seed-2-0-pro: + platform_id: "volcengine" + model_name: "doubao-seed-2-0-pro-260215" + display_name: "豆包 Seed 2.0 Pro" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + is_enabled: true + + # 脚本生成、润色 - 当前默认模型(响应快、性价比高) + deepseek-v3-2: + platform_id: "volcengine" + model_name: "deepseek-v3-2-251201" + display_name: "DeepSeek V3.2" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + is_enabled: true + cost_per_1k_input: 0.002 + cost_per_1k_output: 0.008 + max_tokens_limit: 64000 + + # 轻量级模型(备选) + doubao-seed-2-0-lite: + platform_id: "volcengine" + model_name: "doubao-seed-2-0-lite-260215" + display_name: "豆包 Seed 2.0 Lite" + capabilities: ["script", "polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 4000 + is_enabled: true + + # 长文本模型(备选) + doubao-lite-32k: + platform_id: "volcengine" + model_name: "doubao-lite-32k-240828" + display_name: "豆包 Lite 32K" + capabilities: ["polish", "chat"] + default_params: + temperature: 0.7 + max_tokens: 2000 + is_enabled: true + cost_per_1k_input: 0.0005 + cost_per_1k_output: 0.001 + max_tokens_limit: 32000 + + # ===== 可灵 AI 模型(视频/图像生成)===== + # 使用方式: + # 1. 前往可灵 AI 开发者平台 https://klingai.com/document-api + # 2. 获取 Access Key 和 Secret Key + # 3. 设置到 .env 文件:KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY + + # 文生视频模型 + kling-v3: + platform_id: "klingai" + model_name: "kling-v3" + display_name: "可灵视频 v3 (最新)" + capabilities: ["video_generation", "text2video", "sound_control", "multi_shot"] + default_params: + duration: 5 + aspect_ratio: "16:9" + mode: "pro" + sound: "off" + multi_shot: false + is_enabled: true + + kling-v3-omni: + platform_id: "klingai" + model_name: "kling-v3-omni" + display_name: "可灵视频 v3 Omni" + capabilities: ["video_generation", "text2video", "sound_control", "multi_shot"] + default_params: + duration: 5 + aspect_ratio: "16:9" + mode: "pro" + sound: "off" + is_enabled: true + + kling-v2-6: + platform_id: "klingai" + model_name: "kling-v2.6" + display_name: "可灵视频 v2.6" + capabilities: ["video_generation", "text2video"] + default_params: + duration: 5 + aspect_ratio: "16:9" + mode: "pro" + is_enabled: true + + kling-v2-5-turbo: + platform_id: "klingai" + model_name: "kling-v2.5-turbo" + display_name: "可灵视频 v2.5 Turbo" + capabilities: ["video_generation", "text2video"] + default_params: + duration: 5 + aspect_ratio: "16:9" + mode: "std" + is_enabled: true + + # 图生视频模型 + kling-i2v-v3: + platform_id: "klingai" + model_name: "kling-v3" + display_name: "可灵图生视频 v3 (最新)" + capabilities: ["video_generation", "image2video", "multi_image_reference"] + default_params: + duration: 5 + mode: "pro" + multi_reference: false + is_enabled: true + + kling-i2v-v2-6: + platform_id: "klingai" + model_name: "kling-v2.6" + display_name: "可灵图生视频 v2.6" + capabilities: ["video_generation", "image2video"] + default_params: + duration: 5 + mode: "pro" + is_enabled: true + + kling-i2v-v1-6: + platform_id: "klingai" + model_name: "kling-v1-6" + display_name: "可灵图生视频 v1.6" + capabilities: ["video_generation", "image2video", "multi_image_reference"] + default_params: + duration: 5 + mode: "std" + is_enabled: true + + # 对口型模型 + kling-lip-sync: + platform_id: "klingai" + model_name: "kling-lip-sync" + display_name: "可灵对口型" + capabilities: ["lip_sync", "digital_human"] + default_params: + mode: "text2video" + voice_language: "zh" + voice_speed: 1.0 + is_enabled: true + + # 图像生成模型 + kolors-v1: + platform_id: "klingai" + model_name: "kolors-v1" + display_name: "可图 Kolors v1" + capabilities: ["image_generation", "text2image"] + default_params: + width: 1024 + height: 1024 + is_enabled: true + +# 任务类型到模型的默认映射 +task_defaults: + script: "deepseek-v3-2" # 脚本生成默认模型 (DeepSeek V3.2 - 响应更快) + polish: "deepseek-v3-2" # 润色默认模型 (DeepSeek V3.2) + chat: "deepseek-v3-2" # 聊天默认模型 (DeepSeek V3.2) + video_generation: "kling-v3" # 视频生成首选模型(v3支持多镜头、声音控制) + image2video: "kling-i2v-v3" # 图生视频首选模型 + lip_sync: "kling-lip-sync" # 对口型首选模型 + image_generation: "kolors-v1" # 图像生成首选模型 diff --git a/python-api/docker-compose.yml b/python-api/docker-compose.yml new file mode 100644 index 0000000..83d255b --- /dev/null +++ b/python-api/docker-compose.yml @@ -0,0 +1,98 @@ +services: + # PostgreSQL 数据库 + db: + image: postgres:15-alpine + container_name: meijiaka-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: meijiaka + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - meijiaka-network + + # Redis 缓存 + redis: + image: redis:7-alpine + container_name: meijiaka-redis + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - meijiaka-network + + # FastAPI 应用(开发模式) + api: + build: + context: . + dockerfile: Dockerfile + container_name: meijiaka-api + environment: + - ENV=development + - DEBUG=true + - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/meijiaka + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - SECRET_KEY=dev-secret-key-change-in-production + volumes: + - .:/app + - ~/Documents/Meijiaka:/root/Documents/Meijiaka + ports: + - "8080:8000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + networks: + - meijiaka-network + + # Async Engine Scheduler: 统一调度所有第三方异步任务 + scheduler: + build: + context: . + dockerfile: Dockerfile + container_name: meijiaka-scheduler + environment: + - ENV=development + - DEBUG=true + - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/meijiaka + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - SECRET_KEY=dev-secret-key-change-in-production + volumes: + - .:/app + - ~/Documents/Meijiaka:/root/Documents/Meijiaka + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + command: python -m app.scheduler.main + networks: + - meijiaka-network + +volumes: + postgres_data: + redis_data: + +networks: + meijiaka-network: + driver: bridge diff --git a/python-api/docs/token_manager.md b/python-api/docs/token_manager.md new file mode 100644 index 0000000..00ab63a --- /dev/null +++ b/python-api/docs/token_manager.md @@ -0,0 +1,300 @@ +# TokenManager - 通用 API 认证 Token 管理器 + +## 概述 + +TokenManager 是一个通用的 Token 缓存与自动刷新管理器,用于解决第三方 API 认证 Token 的有效期管理问题。主要解决以下痛点: + +1. **Token 频繁过期**:如 KlingAI 的 JWT Token 只有 30 分钟有效期 +2. **重复请求**:每次 API 调用都重新生成 Token,增加开销 +3. **并发安全**:多个并发请求同时触发 Token 刷新时的竞态条件 +4. **预热机制**:在 Token 过期前主动刷新,避免请求时等待 + +## 特性 + +- ✅ **Token 缓存**:缓存 Token 直到接近过期时间 +- ✅ **自动刷新**:Token 即将过期时自动后台刷新 +- ✅ **并发安全**:使用锁机制确保并发请求只触发一次刷新 +- ✅ **多策略支持**:内置 JWT、OAuth2 策略,支持自定义策略 +- ✅ **多实例隔离**:不同 Provider 的 Token 相互隔离 +- ✅ **预热机制**:提前刷新 Token,确保请求时始终有效 + +## 架构设计 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TokenManager (单例) │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Token Cache │ │ Refresh Lock│ │ Background Tasks │ │ +│ │ {key: info} │ │ {key: Lock} │ │ {preemptive refresh}│ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │JWT Strategy │ │OAuth2 Str. │ │ Custom Str. │ + └─────────────┘ └─────────────┘ └─────────────┘ +``` + +## 快速开始 + +### 1. 基础用法 - 便捷函数 + +```python +from app.core.token_manager import get_jwt_token, get_oauth2_token + +# JWT Token (KlingAI 模式) +token_info = await get_jwt_token( + access_key="your_access_key", + secret_key="your_secret_key", +) +headers = {"Authorization": f"Bearer {token_info.token}"} + +# OAuth2 Token +token_info = await get_oauth2_token( + client_id="your_client_id", + client_secret="your_client_secret", + token_url="https://api.example.com/oauth2/token", +) +``` + +### 2. Provider 集成(推荐) + +```python +from app.core.token_manager import JWTTokenStrategy, TokenManager + +class KlingAIProvider: + def __init__(self, access_key: str, secret_key: str): + self._token_strategy = JWTTokenStrategy( + access_key=access_key, + secret_key=secret_key, + expires_in=1800, # 30分钟 + ) + + 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 api_call(self): + headers = await self._get_headers() + # 使用 headers 发起请求 + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload) as resp: + return await resp.json() +``` + +### 3. 自定义 Token 策略 + +```python +from app.core.token_manager import BaseTokenStrategy, TokenInfo, TokenManager + +class MyCustomStrategy(BaseTokenStrategy): + async def generate(self) -> TokenInfo: + # 实现你的 token 获取逻辑 + token = await fetch_token_from_somewhere() + return TokenInfo( + token=token, + expires_at=time.time() + 3600, + token_type="Bearer", + ) + + def get_cache_key(self) -> str: + # 返回唯一的缓存标识 + return "my_custom_key" + +# 使用 +strategy = MyCustomStrategy() +token_info = await TokenManager.get_instance().get_token(strategy) +``` + +## 核心概念 + +### TokenInfo + +Token 信息数据类: + +```python +@dataclass +class TokenInfo: + token: str # Token 字符串 + expires_at: float # 过期时间戳(秒) + token_type: str # Token 类型(默认 Bearer) + extra_data: dict # 额外数据(如 refresh_token) + + # 属性 + is_expired: bool # 是否已过期 + expires_in: float # 剩余有效时间(秒) + + # 方法 + is_near_expiry(safety_margin=300) -> bool # 是否接近过期 +``` + +### 安全边界(Safety Margin) + +为了防止 Token 在请求过程中过期,TokenManager 使用安全边界机制: + +- 默认安全边界:**5分钟(300秒)** +- 当 Token 剩余有效期小于安全边界时,视为"接近过期" +- 接近过期时会触发刷新 +- 预热机制会在 2 * 安全边界(10分钟)前后台刷新 + +``` +Token 生命周期: + +生成 ──────────────────────────────────────────> 过期 + │ │ + │<─────────── 30分钟有效期 ───────────────────>│ + │ │ + │ │<── 5分钟安全边界 ──>│ │ + │ │ │ │ + │ │ 接近过期,开始刷新 │ │ + │ │ │ │ + │ │<── 10分钟 ──>│ │ │ + │ │ │ │ │ + │ │ 后台预热任务调度 │ │ + │ │ │ │ │ + │ ▼ ▼ ▼ ▼ + 生成 预热 刷新 过期(永不发生) +``` + +## 内置策略 + +### JWTTokenStrategy + +用于 JWT 认证(如 KlingAI): + +```python +strategy = JWTTokenStrategy( + access_key="your_access_key", + secret_key="your_secret_key", + expires_in=1800, # Token 有效期(秒) + algorithm="HS256", # JWT 算法 + token_type="JWT", # Token 类型 +) +``` + +### OAuth2TokenStrategy + +用于 OAuth2 认证: + +```python +strategy = OAuth2TokenStrategy( + client_id="your_client_id", + client_secret="your_client_secret", + token_url="https://api.example.com/oauth2/token", + scope="read write", # 可选 + extra_params={}, # 额外参数 +) +``` + +## API 参考 + +### TokenManager + +```python +class TokenManager: + @classmethod + def get_instance(cls) -> TokenManager: + """获取单例实例""" + + async def get_token( + self, + strategy: BaseTokenStrategy, + force_refresh: bool = False, + ) -> TokenInfo: + """获取有效的 token""" + + async def get_token_string( + self, + strategy: BaseTokenStrategy, + force_refresh: bool = False, + ) -> str: + """获取 token 字符串(带 Bearer 前缀)""" + + async def invalidate(self, strategy: BaseTokenStrategy) -> bool: + """使缓存失效""" + + def clear(self): + """清除所有 token 缓存""" + + def get_stats(self) -> dict: + """获取缓存统计信息""" +``` + +## 并发安全机制 + +TokenManager 使用双重检查锁定(Double-Checked Locking)模式确保并发安全: + +```python +# 伪代码 +async def get_token(strategy): + cache_key = strategy.get_cache_key() + + # 第一次检查(无锁) + if cache_key in cache and not near_expiry: + return cache[cache_key] + + # 获取刷新锁 + async with refresh_locks[cache_key]: + # 第二次检查(有锁) + if cache_key in cache and not near_expiry: + return cache[cache_key] + + # 执行刷新 + new_token = await strategy.generate() + cache[cache_key] = new_token + return new_token +``` + +这样即使有 100 个并发请求同时触发 Token 刷新,也只会执行一次实际的刷新操作。 + +## 测试 + +运行测试: + +```bash +cd python-api +pytest tests/test_token_manager.py -v +``` + +测试覆盖: +- TokenInfo 数据类行为 +- JWT Token 生成 +- Token 缓存机制 +- 并发安全(10 个并发请求只生成 1 个 token) +- 强制刷新 +- 缓存失效 +- OAuth2 支持 +- 多 Provider 隔离 + +## 最佳实践 + +1. **在 Provider 初始化时创建 Strategy**:避免每次请求都创建新的 Strategy 实例 +2. **使用相同的 access_key/secret_key**:确保缓存命中 +3. **不要手动管理 Token 过期**:TokenManager 会自动处理 +4. **定期查看统计信息**:用于监控 Token 使用情况 + +```python +# 调试:查看 Token 缓存统计 +stats = TokenManager.get_instance().get_stats() +print(stats) +# { +# 'total_cached': 3, +# 'active_tasks': 3, +# 'tokens': { +# 'jwt:key1': {'expires_in': 1200, 'is_expired': False, 'is_near_expiry': False}, +# ... +# } +# } +``` + +## 扩展阅读 + +- [KlingAI API 文档](https://klingai.com/document-api) +- [JWT 规范 (RFC 7519)](https://tools.ietf.org/html/rfc7519) +- [OAuth2 规范 (RFC 6749)](https://tools.ietf.org/html/rfc6749) diff --git a/python-api/generate_kling_token.py b/python-api/generate_kling_token.py new file mode 100644 index 0000000..885af31 --- /dev/null +++ b/python-api/generate_kling_token.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +生成 KlingAI JWT 认证 Token +使用环境变量中的 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY +""" + +import os +import time + +from dotenv import load_dotenv +from jose import jwt + +# 加载 .env 文件 +load_dotenv() + +access_key = os.getenv("KLINGAI_ACCESS_KEY", "") +secret_key = os.getenv("KLINGAI_SECRET_KEY", "") + +if not access_key or not secret_key: + print("错误: 请在 .env 文件中配置 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY") + exit(1) + +# JWT 生成算法与代码中一致 +headers = {"alg": "HS256", "typ": "JWT"} +current_time = int(time.time()) +payload = { + "iss": access_key, + "exp": current_time + 1800, # 30分钟过期 + "nbf": current_time - 5, # 5秒前生效 +} + +token = jwt.encode(payload, secret_key, algorithm="HS256", headers=headers) + +print("=" * 60) +print("KlingAI JWT Token 生成成功") +print("=" * 60) +print(f"Access Key: {access_key}") +print(f"生成时间: {current_time}") +print(f"过期时间: {current_time + 1800} (30分钟后)") +print() +print("Token:") +print(token) +print() +print("使用方式:") +print(f"Authorization: Bearer {token}") +print("=" * 60) diff --git a/python-api/mark_timeout_failed.py b/python-api/mark_timeout_failed.py new file mode 100644 index 0000000..76db9a5 --- /dev/null +++ b/python-api/mark_timeout_failed.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +手动把卡住的任务标记失败 +""" + +import redis.asyncio as redis +import json +import asyncio +from app.config import get_settings + +async def main(): + settings = get_settings() + r = redis.from_url(settings.REDIS_URL) + + job_key = "job:task_09285f973d814364" + params_raw = await r.hget(job_key, "params") + if not params_raw: + print("Job not found") + return + + params = json.loads(params_raw) + shots = params["shots"] + + # 找到第3个分镜标记失败 + for i, shot in enumerate(shots): + if shot["id"] == "3" and shot["status"] == "submitted": + shot["status"] = "failed" + shot["error_message"] = "生成超时(手动标记失败)" + print(f"Marked shot 3 as failed") + break + + # 写回 Redis + await r.hset(job_key, "params", json.dumps(params)) + + # 释放槽位 + await r.srem("kling:video_slots", "task_09285f973d814364:3") + + print("Done") + await r.aclose() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python-api/pyproject.toml b/python-api/pyproject.toml new file mode 100644 index 0000000..9e3ae52 --- /dev/null +++ b/python-api/pyproject.toml @@ -0,0 +1,126 @@ +[project] +name = "meijiaka-ai-api" +version = "0.1.0" +description = "美家卡智影 - AI 视频创作后端 API" +authors = [{ name = "Meijiaka Team" }] +readme = "README.md" +requires-python = ">=3.13" +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + # Web 框架 (FastAPI 0.116+ 修复 Starlette 安全漏洞) + "fastapi>=0.116.0", + "uvicorn[standard]~=0.32.0", + "python-multipart~=0.0.20", + + # 认证 & 安全 + "passlib[bcrypt]~=1.7.4", + "bcrypt~=4.2.0", + + # 数据库 + "sqlalchemy[asyncio]~=2.0.36", + "asyncpg~=0.30.0", + + # 缓存 & 任务队列 + "redis~=5.2.0", + + + # 配置 & 验证 + "pydantic~=2.9.0", + "pydantic-settings~=2.6.0", + + # AI / LLM + "openai~=1.58.0", + + # 火山方舟官方 SDK(可选,如不需要可注释掉) + "volcengine-python-sdk[ark]~=5.0.0", + + # HTTP 客户端 + "httpx~=0.28.0", + "aiohttp>=3.13.4", # 安全修复:修复 CVE-2025-XXXX 系列漏洞 + + # 对象存储 + "qiniu~=7.13.0", + + # 工具 + "python-jose[cryptography]~=3.4.0", + + "pyyaml~=6.0.2", + "orjson>=3.11.0", # 安全修复:修复 CVE-2025-XXXX +] + +[project.optional-dependencies] +dev = [ + "pytest~=8.3.0", + "pytest-asyncio~=0.24.0", + "pytest-cov~=6.0.0", + "ruff~=0.8.0", + "black~=24.10.0", + "mypy~=1.14.0", + "bandit[toml]~=1.8.0", # 安全扫描 + "pip-audit~=2.7.0", # 漏洞检测 + "pre-commit~=4.0.0", # Git 钩子 +] + +[project.scripts] +meijiaka-api = "app.main:main" + +[tool.setuptools] +packages = ["app"] + +[tool.black] +line-length = 100 +target-version = ["py313"] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"] +ignore = ["E501", "E402", "N802", "N803", "N806", "N815", "B008", "B904"] + +[tool.mypy] +python_version = "3.13" +strict = false +warn_return_any = false +warn_unused_configs = true +ignore_missing_imports = true +# 逐步修复历史遗留问题 +warn_no_return = false +check_untyped_defs = false +disallow_untyped_defs = false +disallow_incomplete_defs = false + +# ========== 重构防护网:新代码严格模式 ========== +[[tool.mypy.overrides]] +module = ["app.schemas.*", "app.crud.*", "app.scheduler.handlers.*"] +strict = true +warn_return_any = true +check_untyped_defs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +# Redis 客户端 typing 问题(Awaitable[T] | T),暂不严格检查 +[[tool.mypy.overrides]] +module = ["app.scheduler.registry", "app.scheduler.slot_manager"] +strict = false +check_untyped_defs = false +disallow_untyped_defs = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +skips = ["B101", "B104", "B105", "B106", "B107", "B301", "B403", "B404", "B603", "B607"] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/python-api/query_kling_task.py b/python-api/query_kling_task.py new file mode 100644 index 0000000..b9dd1a0 --- /dev/null +++ b/python-api/query_kling_task.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +查询 KlingAI Omni-Video 任务状态 +""" + +import asyncio +import os + +import aiohttp +from dotenv import load_dotenv +from jose import jwt + +# 加载 .env 文件 +load_dotenv() + +access_key = os.getenv("KLINGAI_ACCESS_KEY", "") +secret_key = os.getenv("KLINGAI_SECRET_KEY", "") +base_url = "https://api-beijing.klingai.com" + +if not access_key or not secret_key: + print("错误: 请在 .env 文件中配置 KLINGAI_ACCESS_KEY 和 KLINGAI_SECRET_KEY") + exit(1) + + +def generate_jwt_token() -> str: + """生成 JWT Token""" + import time + + headers = {"alg": "HS256", "typ": "JWT"} + current_time = int(time.time()) + payload = { + "iss": access_key, + "exp": current_time + 1800, + "nbf": current_time - 5, + } + return jwt.encode(payload, secret_key, algorithm="HS256", headers=headers) + + +async def query_task(task_type: str, task_id: str) -> dict: + """查询任务状态""" + token = generate_jwt_token() + url = f"{base_url}/v1/videos/{task_type}/{task_id}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + async with aiohttp.ClientSession() as session, session.get(url, headers=headers) as resp: + data = await resp.json() + return data + + +async def main(): + task_type = "image2video" + task_id = "871485500765933601" + print(f"正在查询任务: {task_type}/{task_id}") + print("-" * 60) + + result = await query_task(task_type, task_id) + code = result.get("code", -1) + message = result.get("message", "") + data = result.get("data", {}) + + print(f"响应 code: {code}") + print(f"响应 message: {message}") + print() + + if code == 0 and data: + task_status = data.get("task_status", "") + if task_status == "succeed": + print("✅ 任务已完成!") + video_url = data.get("video_url", "") + duration = data.get("duration", "") + print(f"视频 URL: {video_url}") + print(f"时长: {duration}s") + elif task_status == "processing" or task_status == "submitted": + print(f"⏳ 任务处理中... 状态: {task_status}") + elif task_status == "failed": + print("❌ 任务失败") + print(f"失败原因: {data.get('err_msg', '未知错误')}") + else: + print(f"任务状态: {task_status}") + print() + print("完整数据:") + import json + + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + print("查询失败") + print("完整响应:") + import json + + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/add_video_path_to_segments.py b/scripts/add_video_path_to_segments.py new file mode 100644 index 0000000..e0a25e9 --- /dev/null +++ b/scripts/add_video_path_to_segments.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +给已有的 segments.json 添加 videoPath 和 videoType 字段 +保留原有的 scene/voiceover/duration 不变 +""" + +import json +from pathlib import Path + +MEIJIAKA_DIR = Path.home() / "Documents" / "Meijiaka" +PROJECTS_DIR = MEIJIAKA_DIR / "projects" + + +def add_video_path(project_id: str): + project_dir = PROJECTS_DIR / project_id + segments_file = project_dir / "segments.json" + videos_dir = project_dir / "videos" + + if not segments_file.exists(): + print(f"segments.json 不存在: {segments_file}") + return False + + if not videos_dir.exists(): + print(f"videos 目录不存在: {videos_dir}") + return False + + # 读取现有的 segments + with open(segments_file, "r", encoding="utf-8") as f: + segments = json.load(f) + + print(f"读取到 {len(segments)} 个分镜") + + # 建立文件名映射 + video_map = {} + for video_path in videos_dir.glob("*.mp4"): + stem = video_path.stem + # scene_1.mp4 -> 1 + parts = stem.split("_") + for part in reversed(parts): + if part.isdigit(): + shot_id = int(part) + video_map[shot_id] = stem + break + + print(f"找到 {len(video_map)} 个视频文件") + + # 更新每个分镜,添加 videoPath + updated = 0 + for seg in segments: + shot_id = seg["id"] + if shot_id in video_map: + # 视频已经在本地,直接用 file:// URL + full_path = videos_dir / f"{video_map[shot_id]}.mp4" + abs_path = str(full_path.absolute()) + if abs_path.startswith("/"): + video_url = "file://" + abs_path + else: + # Windows + normalized = abs_path.replace("\\", "/") + video_url = "file:///" + normalized + seg["videoPath"] = video_url + shot_type = seg.get("type", "segment") + seg["videoType"] = ( + "text2video" if shot_type == "empty_shot" else "avatar-video" + ) + updated += 1 + print(f" ✓ 镜头 {shot_id} 添加 videoPath: {video_url}") + + # 备份原文件 + backup = segments_file.with_suffix(".json.backup2") + segments_file.replace(backup) + print(f"\n原文件备份到: {backup}") + + # 保存更新后的 + with open(segments_file, "w", encoding="utf-8") as f: + json.dump(segments, f, indent=2, ensure_ascii=False) + + print(f"\n完成!更新了 {updated} 个分镜") + print(f"原文件备份在: {backup}") + return True + + +def main(): + import sys + + if len(sys.argv) != 2: + print(f"用法: python {sys.argv[0]} ") + print(f"示例: python {sys.argv[0]} proj_1775626705858_4ngn3k63z") + return + + project_id = sys.argv[1] + add_video_path(project_id) + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_segments_with_videos.py b/scripts/fix_segments_with_videos.py new file mode 100644 index 0000000..7634f7b --- /dev/null +++ b/scripts/fix_segments_with_videos.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +修复 segments.json,给已有分镜添加 videoPath 字段 +用法: python fix_segments_with_videos.py +""" + +import json +import sys +from pathlib import Path + +MEIJIAKA_DIR = Path.home() / "Documents" / "Meijiaka" +PROJECTS_DIR = MEIJIAKA_DIR / "projects" + + +def fix_project(project_id: str): + project_dir = PROJECTS_DIR / project_id + if not project_dir.exists(): + print(f"项目不存在: {project_dir}") + return False + + segments_file = project_dir / "segments.json" + videos_dir = project_dir / "videos" + + if not segments_file.exists(): + print(f"segments.json 不存在: {segments_file}") + return False + + if not videos_dir.exists(): + print(f"videos 目录不存在: {videos_dir}") + return False + + # 读取当前 segments + with open(segments_file, "r", encoding="utf-8") as f: + segments = json.load(f) + + print(f"读取 segments.json,共 {len(segments)} 个分镜:") + for seg in segments: + print( + f" 镜头 {seg['id']}: {seg.get('type', '?')} - {seg.get('scene', '')[:30]}..." + ) + + # 获取所有视频文件 + video_files = list(videos_dir.glob("*.mp4")) + print(f"\n找到 {len(video_files)} 个视频文件:") + + # 建立 shot_id -> filename 映射 + video_map: dict[int, str] = {} + for vf in video_files: + name = vf.name + parts = name.split("_") + for part in reversed(parts): + if part.removesuffix(".mp4").isdigit(): + shot_id = int(part.removesuffix(".mp4")) + video_map[shot_id] = name + print(f" {name} -> 镜头 {shot_id}") + break + + # 更新每个分镜 + updated = 0 + for seg in segments: + shot_id = seg["id"] + if shot_id in video_map: + filename = video_map[shot_id] + # 通过后端 API 访问,这样浏览器和 Tauri 都能工作 + video_url = f"http://127.0.0.1:8080/api/v1/video/download/{filename.removesuffix('.mp4')}" + shot_type = seg.get("type", "segment") + video_type = "text2video" if shot_type == "empty_shot" else "avatar-video" + seg["videoPath"] = video_url + seg["videoType"] = video_type + updated += 1 + print(f" ✓ 镜头 {shot_id} 添加: {video_url}") + else: + print(f" ○ 镜头 {shot_id} 没有找到对应视频文件") + + # 备份原文件 + backup = segments_file.with_suffix(".json.backup") + if not backup.exists(): + segments_file.replace(backup) + print(f"\n备份已保存到: {backup}") + + # 保存更新后的 + with open(segments_file, "w", encoding="utf-8") as f: + json.dump(segments, f, indent=2, ensure_ascii=False) + + print(f"\n完成!更新了 {updated} 个分镜的 videoPath") + print("\n请刷新浏览器页面,重新打开项目,现在点击镜头应该就能预览了") + return True + + +def main(): + if len(sys.argv) != 2: + print(f"用法: python {sys.argv[0]} ") + print(f"示例: python {sys.argv[0]} proj_1775626705858_4ngn3k63z") + return + + project_id = sys.argv[1] + fix_project(project_id) + + +if __name__ == "__main__": + main() diff --git a/scripts/recover_video_paths.py b/scripts/recover_video_paths.py new file mode 100644 index 0000000..482a31c --- /dev/null +++ b/scripts/recover_video_paths.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +恢复已生成视频的路径信息 +扫描项目 videos 目录中的 .mp4 文件,并更新 segments.json 中的 videoPath +""" + +import json +from pathlib import Path +from typing import Optional + +# 基础目录 +MEIJIAKA_DIR = Path.home() / "Documents" / "Meijiaka" +PROJECTS_DIR = MEIJIAKA_DIR / "projects" + + +def parse_shot_id_from_filename(filename: str) -> Optional[int]: + """从文件名解析 shot_id""" + # scene_1.mp4 -> 1 + # scene_shot_01.mp4 -> 1 + name = filename.removesuffix(".mp4") + parts = name.split("_") + + # 反向查找,找到第一个数字部分 + for part in reversed(parts): + if part.isdigit(): + return int(part) + + return None + + +def recover_project(project_dir: Path): + """恢复单个项目的视频路径""" + meta_file = project_dir / "meta.json" + segments_file = project_dir / "segments.json" + + # 检查视频目录 + videos_dir = project_dir / "videos" + if not videos_dir.exists(): + print(f"[{project_dir.name}] 无 videos 目录,跳过") + return False + + # 获取所有 mp4 文件 + video_files = list(videos_dir.glob("*.mp4")) + if not video_files: + print(f"[{project_dir.name}] 无视频文件,跳过") + return False + + print(f"\n[{project_dir.name}] 发现 {len(video_files)} 个视频文件:") + + # 解析所有视频 + video_info = [] + for vf in video_files: + shot_id = parse_shot_id_from_filename(vf.name) + if shot_id is not None: + video_info.append((shot_id, vf)) + print(f" - {vf.name} -> 镜头 ID: {shot_id}") + else: + print(f" - {vf.name} -> 无法解析 ID,跳过") + + if not video_info: + print(f"[{project_dir.name}] 没有可恢复的视频,跳过") + return False + + # 读取现有的 segments.json + segments = [] + if segments_file.exists() and segments_file.stat().st_size > 2: + with open(segments_file, "r", encoding="utf-8") as f: + try: + segments = json.load(f) + print(f" 读取现有 segments.json,共 {len(segments)} 个分镜") + except json.JSONDecodeError: + print(" segments.json 损坏,将重新创建") + segments = [] + else: + print(" segments.json 不存在或为空,将重新创建") + segments = [] + + # 如果没有 segments,从头创建 + if not segments: + print(" 创建新的分镜数据") + for shot_id, vf in sorted(video_info, key=lambda x: x[0]): + # 判断类型:文件名中包含 empty 则为空镜 + is_empty = "empty" in vf.name or "shot" in vf.name + segment_type = "empty_shot" if is_empty else "segment" + segments.append( + { + "id": shot_id, + "type": segment_type, + "scene": "", + "voiceover": "", + "duration": "5s", + } + ) + print(f" + 镜头 {shot_id} ({segment_type})") + + # 更新 videoPath + updated = 0 + for shot_id, vf in video_info: + filename = vf.name + # 找到对应的分镜 + found = False + for seg in segments: + if seg.get("id") == shot_id: + # 确定类型 + shot_type = seg.get("type", "segment") + video_type = ( + "text2video" if shot_type == "empty_shot" else "avatar-video" + ) + # 构建可访问的 URL + video_url = f"http://127.0.0.1:8080/api/v1/video/download/{filename.removesuffix('.mp4')}" + seg["videoPath"] = video_url + seg["videoType"] = video_type + found = True + updated += 1 + print(f" ✓ 更新镜头 {shot_id}: {video_url}") + break + + if not found: + print(f" ✗ 未找到分镜 {shot_id},跳过") + + # 创建 meta.json 如果不存在 + if not meta_file.exists(): + print(" 创建默认 meta.json") + meta = { + "id": project_dir.name, + "title": f"项目 {project_dir.name}", + "status": "draft", + "createdAt": int(project_dir.stat().st_ctime), + "updatedAt": int(project_dir.stat().st_mtime), + } + with open(meta_file, "w", encoding="utf-8") as f: + json.dump(meta, f, indent=2, ensure_ascii=False) + + # 写回 segments.json + with open(segments_file, "w", encoding="utf-8") as f: + json.dump(segments, f, indent=2, ensure_ascii=False) + + print(f" [{project_dir.name}] 完成:更新 {updated} 个视频路径") + return True + + +def main(): + if not PROJECTS_DIR.exists(): + print(f"项目目录不存在: {PROJECTS_DIR}") + return + + # 遍历所有项目目录 + for project_dir in PROJECTS_DIR.iterdir(): + if project_dir.is_dir(): + recover_project(project_dir) + + print("\n=== 恢复完成 ===") + + +if __name__ == "__main__": + main() diff --git a/tauri-app/.env b/tauri-app/.env new file mode 100644 index 0000000..cd9e873 --- /dev/null +++ b/tauri-app/.env @@ -0,0 +1,3 @@ +# Vite 开发环境变量 +# 前端默认连接的 Python API 地址 +VITE_API_BASE_URL=http://127.0.0.1:8080/api/v1 diff --git a/tauri-app/.gitignore b/tauri-app/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/tauri-app/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/tauri-app/.prettierrc b/tauri-app/.prettierrc new file mode 100644 index 0000000..639e153 --- /dev/null +++ b/tauri-app/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf", + "useTabs": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "bracketSameLine": false, + "embeddedLanguageFormatting": "auto" +} diff --git a/tauri-app/.stylelintrc.json b/tauri-app/.stylelintrc.json new file mode 100644 index 0000000..aaa33fc --- /dev/null +++ b/tauri-app/.stylelintrc.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "stylelint-config-standard" + ], + "rules": { + "custom-property-pattern": "^[a-z][a-z0-9-]*$", + + "declaration-property-value-disallowed-list": { + "border-radius": ["1px", "2px", "3px", "4px", "5px", "6px", "7px", "8px", "9px", "10px", "11px", "12px", "13px", "14px", "15px", "16px"], + "font-size": ["9px", "10px", "11px", "12px", "13px", "14px", "15px", "16px", "17px", "18px", "19px", "20px", "21px", "22px"] + }, + + "declaration-empty-line-before": null, + "selector-class-pattern": null, + "keyframes-name-pattern": null, + + "no-descending-specificity": null, + "declaration-block-no-duplicate-properties": null, + "number-max-precision": 4, + "max-nesting-depth": null + } +} diff --git a/tauri-app/.vscode/extensions.json b/tauri-app/.vscode/extensions.json new file mode 100644 index 0000000..0fb39b1 --- /dev/null +++ b/tauri-app/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "stylelint.vscode-stylelint", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/tauri-app/AGENTS.md b/tauri-app/AGENTS.md new file mode 100644 index 0000000..49cbc3c --- /dev/null +++ b/tauri-app/AGENTS.md @@ -0,0 +1,249 @@ +# AGENTS.md — 美家卡智影 + +> 本文档面向 AI Coding Agent。项目主要使用中文进行注释和界面文案,文档亦以中文撰写。 + +--- + +## 项目概述 + +**美家卡智影**(产品名)是一款基于 Tauri v2 + React 19 + TypeScript 的桌面端 AI 视频创作应用。 + +- **产品标识**: `cn.meijiaka.ai-video` +- **版本**: `0.1.0` +- **窗口尺寸**: 1200×800,不可缩放(`resizable: false`) +- **核心功能**: AI 脚本生成、AI 配音、数字人视频生成、视频合成(FFmpeg)、项目本地持久化 + +### 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端框架 | React 19 + TypeScript | +| 构建工具 | Vite 7 | +| 桌面壳 | Tauri v2 (Rust) | +| 状态管理 | Zustand 5 + Immer 11 | +| 路由 | `react-router-dom` (BrowserRouter) + 自定义 `NavigationContext` 做页面切换 | +| 数据请求 | 自研智能路由客户端 (`src/api/client.ts`) + SWR | +| 测试 | Vitest 4 + jsdom + `@testing-library/react` | +| 虚拟列表 | `@tanstack/react-virtual` | +| 外部依赖 | FFmpeg(以 Tauri Sidecar 形式打包) | + +### 运行时架构 + +采用**混合通信架构**: + +1. **纯数据 API**(脚本、配音、视频生成)→ 前端通过 HTTP **直连 Python 后端**(`http://127.0.0.1:8080/api/v1`)。 +2. **需要本地系统能力**(FFmpeg 视频合成、文件系统读写、认证)→ 走 **Tauri IPC → Rust 层** 处理。 + +> 新增纯数据 API 时,**无需修改 Rust 代码**,直接在 `src/api/modules/` 下使用 `client.post/get` 调用即可。 + +--- + +## 目录结构 + +``` +. +├── index.html # Vite 入口 HTML +├── package.json # Node 依赖与脚本 +├── vite.config.ts # Vite 配置(端口 1420,Tauri 适配) +├── vitest.config.ts # 测试配置 +├── tsconfig.json # TypeScript 主配置(strict: true) +├── tsconfig.node.json # Node 工具链 TS 配置 +├── src/ +│ ├── main.tsx # React 挂载入口 +│ ├── App.tsx # 根组件:导航上下文、主题、侧边栏布局 +│ ├── api/ +│ │ ├── client.ts # 智能路由客户端(HTTP / IPC 自动选择) +│ │ ├── types.ts # 通用 API 类型 +│ │ └── modules/ # 按领域拆分的 API 模块 +│ │ ├── auth.ts +│ │ ├── script.ts # 脚本生成(含 SSE 流式接口) +│ │ ├── voice.ts +│ │ ├── video.ts +│ │ ├── videoComposite.ts # 视频合成(走 IPC) +│ │ ├── cover.ts +│ │ └── system.ts # 项目持久化 +│ ├── components/ # 可复用组件(PascalCase 文件夹) +│ │ ├── Layout/ +│ │ ├── Modal/ +│ │ ├── Toast/ +│ │ ├── ErrorBoundary/ +│ │ ├── VirtualShotList/ +│ │ └── ... +│ ├── hooks/ # 自定义 React Hooks +│ ├── pages/ # 页面级组件(PascalCase 文件夹) +│ │ ├── VideoCreation/ # 核心创作流程(脚本→音频→封面→合成) +│ │ ├── ContentManagement/ +│ │ │ ├── VoiceClone/ +│ │ │ ├── DigitalHuman/ +│ │ │ └── MyWorks/ +│ │ ├── Settings/ +│ │ ├── Profile/ +│ │ └── Login/ +│ ├── store/ # Zustand 状态管理 +│ │ ├── authStore.ts +│ │ ├── projectStore.ts # 项目数据(分镜、空镜、音频、封面等) +│ │ ├── settingsStore.ts +│ │ ├── uiStore.ts # Toast 等 UI 状态 +│ │ ├── Provider.tsx +│ │ ├── index.ts +│ │ └── __tests__/ # Store 单元测试 +│ ├── styles/ +│ │ ├── variables.css # CSS 变量(含主题变量) +│ │ └── global.css # 全局样式 +│ └── __tests__/ +│ └── setup.ts # Vitest 全局 setup(mock localStorage / Tauri API) +├── src-tauri/ +│ ├── Cargo.toml # Rust 依赖 +│ ├── tauri.conf.json # Tauri 应用配置(CSP、窗口、打包、sidecar) +│ ├── build.rs +│ ├── binaries/ffmpeg # FFmpeg sidecar 二进制 +│ ├── icons/ # 应用图标 +│ └── src/ +│ ├── main.rs # 程序入口 +│ ├── lib.rs # Tauri Builder、Command 定义、Python 代理 +│ ├── ffmpeg_cmd.rs # FFmpeg 命令封装(拼接、音画合并、封面转视频) +│ └── persistence.rs # 项目本地文件读写 +└── public/ # 静态资源 +``` + +--- + +## 构建与开发命令 + +```bash +# 前端开发服务器(Vite,端口 1420) +npm run dev + +# 生产构建(tsc + vite build) +npm run build + +# Tauri 开发(启动 Rust + 前端) +npm run tauri dev + +# Tauri 生产打包 +npm run tauri build + +# 运行测试 +npm run test + +# Vitest UI 模式 +npm run test:ui + +# 测试覆盖率 +npm run test:coverage +``` + +### 关键配置说明 + +- **Vite 端口固定为 1420**(`strictPort: true`),与 Tauri `devUrl` 对齐。 +- **Tauri 开发时忽略 `src-tauri/` 目录的 watch**,避免前端热更新与 Rust 编译互相干扰。 +- **路径别名**: `@/` 映射到 `./src`(在 `vitest.config.ts` 中配置)。 + +--- + +## 代码风格与约定 + +### 命名规范 + +- **组件/页面文件夹**: `PascalCase`(如 `VideoCreation`、`ErrorBoundary`) +- **Store/Hooks/API 文件**: `camelCase`(如 `authStore.ts`、`useProjectData.ts`) +- **类型/接口**: `PascalCase` +- **常量**: `UPPER_SNAKE_CASE`(Rust 层) + +### 注释语言 + +- 项目内**统一使用中文注释**。 +- 关键架构决策需在代码中以多行注释说明(参考 `src/api/client.ts` 顶部的“混合模式智能路由”注释)。 + +### TypeScript 配置 + +- `strict: true` 已开启。 +- `noUnusedLocals: true`、`noUnusedParameters: true` 已开启,未使用变量会报错。 +- `jsx: "react-jsx"`,无需手动引入 `React`。 + +### 状态管理约定 + +- 使用 **Zustand + Immer** 进行不可变更新。 +- `projectStore` 使用自定义 `persist` 存储,将项目数据通过 Tauri IPC 持久化到本地文件系统(`app_config_dir/current_project.json`),而不是 localStorage。 +- 其他 Store(如 `authStore`、`settingsStore`)使用 `localStorage` 做持久化。 + +### API 开发流程 + +1. **判断是否需要本地能力**(FFmpeg、文件系统、系统调用)。 +2. **不需要** → 直接在 `src/api/modules/` 使用 `client.get/post/put/delete` 调用 Python HTTP API。 +3. **需要** → 将 endpoint 加入 `src/api/client.ts` 的 `RUST_IPC_APIS` 集合,并在 `src-tauri/src/lib.rs` 中实现对应的 `#[tauri::command]` 处理器。 + +--- + +## 测试说明 + +### 测试框架 + +- **Vitest**(globals: true,environment: `jsdom`) +- **@testing-library/react** 用于测试 Hooks 和组件 +- **@testing-library/jest-dom** 提供自定义 matchers + +### 测试文件位置 + +- 全局 setup: `src/__tests__/setup.ts` +- Store 测试: `src/store/__tests__/*.test.tsx` +- 组件/页面测试: 建议放在被测文件同目录或 `__tests__` 子目录中 + +### Mock 策略 + +`setup.ts` 中已全局 mock 以下内容: + +- `localStorage`(完整 mock) +- `@tauri-apps/api/core` 的 `invoke` 方法 +- `window.__TAURI_INTERNALS__` + +每个测试后自动调用 `vi.clearAllMocks()`。 + +### 运行示例 + +```bash +# 单次运行 +npm run test + +# 交互式 UI +npm run test:ui +``` + +--- + +## 安全与部署 + +### CSP 配置 + +`src-tauri/tauri.conf.json` 中已配置 Content Security Policy: + +- `default-src`: `'self'` +- `connect-src`: `'self' http://localhost:8080 http://127.0.0.1:8080` +- `img-src` / `media-src`: 允许 `http://localhost:8080` 及 `blob:`、`data:` + +### 外部二进制 + +- FFmpeg 作为 **sidecar** 打包(`bundle.externalBin: ["binaries/ffmpeg"]`)。 +- Rust 层通过 `tauri_plugin_shell` 的 `sidecar("ffmpeg")` 调用。 +- 合成过程中会解析 FFmpeg stderr 输出中的 `time=` 字段,并通过 Tauri Event (`ffmpeg-progress`) 向前端发送进度。 + +### 项目文件存储路径 + +- **项目持久化文件**: `{app_config_dir}/current_project.json`(由 `persistence.rs` 管理)。 +- **合成临时文件**: `{document_dir}/Meijiaka/Projects/`(由 `lib.rs` 中的 `get_project_dir` 管理)。 + +### 认证状态 + +当前登录接口 (`auth_login`) 返回 **Mock 数据**,仅用于开发阶段。生产环境需替换为真实认证逻辑。 + +--- + +## 给 Agent 的快速检查清单 + +在修改代码前,建议确认以下事项: + +1. **新增 API 是否需要 Rust 层?** 不需要则只改前端 `src/api/modules/`。 +2. **修改 Store 后是否影响持久化?** `projectStore` 的 `partialize` 字段决定哪些状态会被保存到本地文件。 +3. **新增组件是否遵循 PascalCase 文件夹约定?** +4. **测试是否通过?** 运行 `npm run test`。 +5. **Tauri 配置变更后是否需要重新 `tauri dev`?** 是的,`tauri.conf.json` 或 `Cargo.toml` 变更后需重启 Tauri 进程。 diff --git a/tauri-app/README.md b/tauri-app/README.md new file mode 100644 index 0000000..102e366 --- /dev/null +++ b/tauri-app/README.md @@ -0,0 +1,7 @@ +# Tauri + React + Typescript + +This template should help get you started developing with Tauri, React and Typescript in Vite. + +## Recommended IDE Setup + +- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) diff --git a/tauri-app/assets/subtitle-template.ass b/tauri-app/assets/subtitle-template.ass new file mode 100644 index 0000000..44b0e8e --- /dev/null +++ b/tauri-app/assets/subtitle-template.ass @@ -0,0 +1,19 @@ +[Script Info] +; Script generated by Meijiaka AI Video +; Style: 抖音美好体 - 抖音风格字幕 +ScriptType: v4.00+ +PlayResX: 1920 +PlayResY: 1080 +ScaledBorderAndShadow: yes +Video Aspect Ratio: 0 +Video Rate: 25 +Audio Rate: 48000 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Douyin-Diamond,抖音美好体,72,&H00FFFFFF,&H00000000,&H00141414,&H00000000,1,0,0,0,100,100,0,0,1,3,0,2,20,20,80,1 +Style: Douyin-Bold,抖音美好体,64,&H00FFFFFF,&H00000000,&H00141414,&H00000000,1,0,0,0,100,100,0,0,1,2,0,2,20,20,60,1 +Style: Douyin-Small,抖音美好体,54,&H00EEEEEE,&H00000000,&H00141414,&H00000000,1,0,0,0,100,100,0,0,1,2,0,2,20,20,40,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text diff --git a/tauri-app/docs/data-architecture.md b/tauri-app/docs/data-architecture.md new file mode 100644 index 0000000..a48a436 --- /dev/null +++ b/tauri-app/docs/data-architecture.md @@ -0,0 +1,232 @@ +# 美家卡智影 - 数据架构设计 + +## 1. 架构目标 + +- **离线优先**:无网络时也能正常使用 +- **云端同步**:多设备间数据同步 +- **多媒体本地存储**:大文件存本地目录,小数据存云端 +- **自动冲突解决**:简化用户操作 + +## 2. 数据分层 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 云端 (PostgreSQL) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ users │ │ projects │ │ avatars │ ... │ +│ │ (用户账号) │ │ (项目元数据) │ │ (形象元数据) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ 小数据、结构数据、需要同步的数据 │ +└─────────────────────────────────────────────────────────────────┘ + ↑↓ 同步 +┌─────────────────────────────────────────────────────────────────┐ +│ 本地 (Tauri + 浏览器) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SQLite / 文件系统 (~/Documents/Meijiaka/) │ │ +│ │ ├─ projects/ # 项目数据 │ │ +│ │ │ └─ {project_id}/ │ │ +│ │ │ ├─ project.json # 项目配置 │ │ +│ │ │ ├─ segments/ # 分镜数据 │ │ +│ │ │ ├─ videos/ # 生成的视频 │ │ +│ │ │ └─ covers/ # 封面图片 │ │ +│ │ ├─ avatars/ # 克隆形象 │ │ +│ │ │ └─ {avatar_id}/ │ │ +│ │ │ ├─ avatar.json # 形象配置 │ │ +│ │ │ └─ video.mp4 # 形象视频 │ │ +│ │ └─ temp/ # 临时文件 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SWR Cache (内存) │ │ +│ │ - API 响应缓存 │ │ +│ │ - 乐观更新队列 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 3. 数据分类 + +### 3.1 云端优先数据(小数据,结构化) + +| 数据类型 | 存储位置 | 同步策略 | 说明 | +|---------|---------|---------|------| +| 用户信息 | 云端 | 实时 | 账号、设置偏好 | +| 项目列表 | 云端 | 实时 | 项目ID、标题、状态、更新时间 | +| 形象元数据 | 云端 | 实时 | 形象ID、名称、云端视频URL | +| 成本统计 | 云端 | 实时 | API 调用记录、费用 | +| 脚本内容 | 云端+本地 | 双向同步 | 分镜文案、场景描述 | + +### 3.2 本地优先数据(大文件,多媒体) + +| 数据类型 | 存储位置 | 同步策略 | 说明 | +|---------|---------|---------|------| +| 形象视频 | 本地 | 按需上传 | 克隆形象的原视频文件 | +| 生成视频 | 本地 | 可选上传 | AI生成的分镜视频 | +| 封面图片 | 本地 | 可选上传 | 项目封面 | +| 合成视频 | 本地 | 可选上传 | 最终成片 | +| 临时文件 | 本地 | 不同步 | 处理过程中的缓存 | + +## 4. 同步策略 + +### 4.1 自动同步(实时) +```typescript +// 云端优先数据 - 自动同步 +- 用户修改脚本 → 立即保存到云端 +- 切换形象 → 立即同步到云端 +- 项目状态变更 → 实时更新 +``` + +### 4.2 手动同步(用户触发) +```typescript +// 大文件 - 手动同步 +- 上传形象视频到云端备份 +- 下载云端形象到本地 +- 分享项目(打包上传) +``` + +### 4.3 离线队列 +```typescript +// 离线时操作进入队列 +interface OfflineQueue { + id: string; + type: 'create' | 'update' | 'delete'; + entity: 'project' | 'segment' | 'avatar'; + data: any; + timestamp: number; + retryCount: number; +} +``` + +## 5. 冲突解决 + +### 5.1 简单策略:最后写入优先 +```typescript +// 基于 updatedAt 时间戳 +const resolveConflict = (local: Data, remote: Data) => { + return local.updatedAt > remote.updatedAt ? local : remote; +}; +``` + +### 5.2 字段级合并(可选) +```typescript +// 不同字段分别决定 +const mergeConflict = (local: Project, remote: Project) => { + return { + ...remote, + segments: local.updatedAt > remote.updatedAt + ? local.segments + : remote.segments, + coverPath: local.coverUpdatedAt > remote.coverUpdatedAt + ? local.coverPath + : remote.coverPath, + }; +}; +``` + +## 6. 存储实现 + +### 6.1 本地目录结构 +``` +~/Documents/Meijiaka/ # 用户文档目录 +├── config.json # 全局配置 +├── cache/ # 缓存目录 +│ └── swr/ # SWR 缓存 +├── projects/ # 项目数据 +│ └── {project_id}/ +│ ├── meta.json # 项目元数据 +│ ├── segments.json # 分镜数据 +│ ├── assets/ # 资源文件 +│ │ ├── videos/ # 生成视频 +│ │ ├── covers/ # 封面图片 +│ │ └── temp/ # 临时文件 +│ └── exports/ # 导出文件 +├── avatars/ # 克隆形象 +│ └── {avatar_id}/ +│ ├── meta.json # 形象配置 +│ └── source.mp4 # 原视频 +└── voices/ # 克隆音色 + └── {voice_id}/ + └── sample.mp3 +``` + +### 6.2 数据存储接口 +```typescript +// 统一存储接口 +interface StorageAdapter { + // 项目数据 + saveProject(project: Project): Promise; + loadProject(projectId: string): Promise; + deleteProject(projectId: string): Promise; + listProjects(): Promise; + + // 形象数据 + saveAvatar(avatar: Avatar): Promise; + loadAvatar(avatarId: string): Promise; + deleteAvatar(avatarId: string): Promise; + listAvatars(): Promise; + + // 多媒体文件 + saveMedia(projectId: string, file: File, type: MediaType): Promise; + loadMedia(path: string): Promise; + deleteMedia(path: string): Promise; +} +``` + +## 7. 状态管理 + +### 7.1 分层状态 +```typescript +// 1. 服务端状态 (SWR) +const { data: projects } = useSWR('/api/projects', fetcher); + +// 2. 本地持久化状态 (Zustand + Storage) +const projectStore = useProjectStore(); // 自动持久化到本地文件 + +// 3. 临时 UI 状态 (React State) +const [selectedTab, setSelectedTab] = useState('script'); +``` + +### 7.2 同步 hook +```typescript +// 自动同步 hook +function useSyncProject(projectId: string) { + const { data: remoteProject } = useSWR(`/api/projects/${projectId}`); + const localProject = useProjectStore(state => state.project); + const sync = useProjectStore(state => state.sync); + + useEffect(() => { + if (remoteProject && localProject) { + sync(remoteProject, localProject); + } + }, [remoteProject, localProject]); +} +``` + +## 8. 迁移路径 + +### Phase 1: 本地文件存储(当前) +- [x] 项目数据存 Tauri 本地 +- [x] 形象数据存 localStorage + +### Phase 2: 云端同步(下一步) +- [ ] 后端新增 project/avatar 表 +- [ ] 实现双向同步逻辑 +- [ ] 离线队列 + +### Phase 3: 多媒体管理 +- [ ] 本地目录结构 +- [ ] 文件导入/导出 +- [ ] 云端备份(可选) + +## 9. 关键决策 + +### Q1: 是否需要本地 SQLite? +**建议**:Phase 1 不需要,JSON 文件足够简单。Phase 2 如需复杂查询再引入。 + +### Q2: 如何处理大文件同步? +**建议**:不上传大文件,只在本地存储。用户需要跨设备时,提供"导出/导入"功能。 + +### Q3: 离线支持到什么程度? +**建议**: +- 脚本编辑:完全离线 +- 视频生成:需要联网(AI 服务) +- 视频合成:可离线(本地 FFmpeg) diff --git a/tauri-app/docs/spacing-guide.md b/tauri-app/docs/spacing-guide.md new file mode 100644 index 0000000..6d18cdc --- /dev/null +++ b/tauri-app/docs/spacing-guide.md @@ -0,0 +1,95 @@ +# 间距规范指南 (Spacing Guide) + +## 间距系统 + +基于 4px 网格系统,提供统一的视觉节奏: + +| 变量 | 值 | 使用场景 | +|------|-----|----------| +| `--spacing-2xs` | 2px | 微调控件、边框线、极细间距 | +| `--spacing-xs` | 4px | 紧凑间隙、图标边距、行内元素 | +| `--spacing-sm` | 8px | 小间隙、按钮内边距-y、列表项 | +| `--spacing-md` | 12px | 标准间隙、卡片内边距、表单字段 | +| `--spacing-lg` | 16px | 大间隙、区块间距、内容分组 | +| `--spacing-xl` | 24px | 页面区块、主要内容分隔 | +| `--spacing-2xl` | 32px | 大区块间距、页面边距 | +| `--spacing-3xl` | 48px | 页面级间距、Hero 区域 | + +## 使用原则 + +1. **优先使用变量**,禁止随意写死数值 +2. **就近取整**:6px → 4px 或 8px;10px → 8px 或 12px +3. **保持一致性**:同一场景下使用相同间距 +4. **响应式设计**:大屏适当增加,小屏适当减少 + +## 常见场景规范 + +### 组件间距 +```css +/* 按钮内边距 */ +.btn-sm { padding: var(--spacing-xs) var(--spacing-sm); } /* 4px 8px */ +.btn-md { padding: var(--spacing-sm) var(--spacing-md); } /* 8px 12px */ +.btn-lg { padding: var(--spacing-sm) var(--spacing-lg); } /* 8px 16px */ + +/* 卡片内边距 */ +.card-sm { padding: var(--spacing-sm); } /* 8px */ +.card-md { padding: var(--spacing-md); } /* 12px */ +.card-lg { padding: var(--spacing-lg); } /* 16px */ + +/* 列表间隙 */ +.list-xs { gap: var(--spacing-xs); } /* 4px */ +.list-sm { gap: var(--spacing-sm); } /* 8px */ +.list-md { gap: var(--spacing-md); } /* 12px */ +``` + +### 页面布局 +```css +/* 页面边距 */ +.page-padding { padding: var(--spacing-lg) var(--spacing-xl); } /* 16px 24px */ + +/* 区块间距 */ +.section-gap { margin-bottom: var(--spacing-xl); } /* 24px */ + +/* 表单字段间距 */ +.form-field-gap { margin-bottom: var(--spacing-md); } /* 12px */ +``` + +## 迁移指南 + +| 旧值 | 新值 | 说明 | +|------|------|------| +| 2px | `--spacing-2xs` | 直接替换 | +| 4px | `--spacing-xs` | 直接替换 | +| 6px | `--spacing-xs` 或 `--spacing-sm` | 根据视觉轻重选择 4px 或 8px | +| 8px | `--spacing-sm` | 直接替换 | +| 10px | `--spacing-sm` 或 `--spacing-md` | 选择 8px 或 12px | +| 12px | `--spacing-md` | 直接替换 | +| 14px | `--spacing-md` 或 `--spacing-lg` | 选择 12px 或 16px | +| 15px | `--spacing-lg` | 改为 16px | +| 16px | `--spacing-lg` | 直接替换 | +| 18px | `--spacing-lg` | 改为 16px | +| 20px | `--spacing-xl` | 改为 24px 或保留特殊情况 | +| 24px | `--spacing-xl` | 直接替换 | +| 32px | `--spacing-2xl` | 直接替换 | +| 40px | `--spacing-2xl` 或 `--spacing-3xl` | 选择 32px 或 48px | +| 48px | `--spacing-3xl` | 直接替换 | + +## 迁移进度 + +### ✅ 已完成 +- [x] variables.css - 添加 --spacing-2xs: 2px +- [x] ContentManagement.css - 全部标准化 +- [x] VideoGeneration.css - 主要间距标准化 +- [x] ScriptCreation.css - 间距标准化 +- [x] global.css - Badge 组件间距标准化 +- [x] Toast.css - 间距标准化 +- [x] CoverDesign.tsx - 间距标准化 +- [x] VideoGeneration.tsx - 间距标准化 +- [x] ScriptCreation.tsx - 间距标准化 +- [x] VideoCompositeDebug.tsx - 间距标准化 +- [x] VideoComposite.tsx - 间距标准化 +- [x] AvatarUploadModal.tsx - 间距标准化 + +### 🔄 待处理(设计相关,需单独评估) +- [ ] VideoCreation.css - 封面模板样式(约 25 处,有设计注释) + - 这些值用于视觉对齐,需要设计师确认后再调整 diff --git a/tauri-app/eslint.config.js b/tauri-app/eslint.config.js new file mode 100644 index 0000000..c8d5a52 --- /dev/null +++ b/tauri-app/eslint.config.js @@ -0,0 +1,57 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; + +export default tseslint.config( + // 全局忽略 + { + ignores: ['dist', 'node_modules', 'src-tauri', 'src/api/generated', 'src/_unused'], + }, + + // 基础规则 + js.configs.recommended, + ...tseslint.configs.recommended, + + // React + TypeScript 文件 + { + files: ['**/*.{ts,tsx}'], + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + settings: { + react: { + version: 'detect', + }, + }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + // React + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react-hooks/set-state-in-effect': 'warn', + 'react-refresh/only-export-components': 'warn', + 'react/no-unescaped-entities': 'warn', + + // TypeScript + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + + // 通用 + 'no-console': ['warn', { allow: ['warn', 'error', 'info'] }], + 'no-constant-condition': 'warn', + eqeqeq: ['warn', 'always'], + curly: ['warn', 'all'], + 'no-var': 'error', + 'prefer-const': 'warn', + }, + }, +); diff --git a/tauri-app/index.html b/tauri-app/index.html new file mode 100644 index 0000000..080af10 --- /dev/null +++ b/tauri-app/index.html @@ -0,0 +1,24 @@ + + + + + + + + 美家卡 智影 + + + + +
+ + + + \ No newline at end of file diff --git a/tauri-app/package-lock.json b/tauri-app/package-lock.json new file mode 100644 index 0000000..429c685 --- /dev/null +++ b/tauri-app/package-lock.json @@ -0,0 +1,8172 @@ +{ + "name": "tauri-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tauri-app", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-virtual": "^3.13.23", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-fs": "^2.5.0", + "@tauri-apps/plugin-opener": "^2", + "assjs": "^0.1.5", + "immer": "^11.1.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.13.1", + "swr": "^2.4.1", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "jsdom": "^29.0.1", + "openapi-typescript": "^7.13.0", + "prettier": "^3.2.5", + "stylelint": "^16.2.1", + "stylelint-config-standard": "^36.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.58.0", + "vite": "^7.3.2", + "vitest": "^4.1.1" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", + "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.4.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@dual-bundle/import-meta-resolve": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", + "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/JounQin" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.11", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz", + "integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.0", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.0", + "@tauri-apps/cli-darwin-x64": "2.10.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-musl": "2.10.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.0", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", + "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", + "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", + "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", + "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", + "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", + "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", + "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", + "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", + "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", + "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz", + "integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.0.tgz", + "integrity": "sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/assjs": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/assjs/-/assjs-0.1.5.tgz", + "integrity": "sha512-xJMpkFQU16scCMAK4wNnkVO/rhZnMqrwUTmlAVoEmzg5Ywl8LvZX1yWwAvQ1X4eRaGyIa7UJ1iZcKtbzmelEDg==", + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", + "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.9.0" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", + "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", + "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylelint": { + "version": "16.26.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", + "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-syntax-patches-for-csstree": "^1.0.19", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3", + "@csstools/selector-specificity": "^5.0.0", + "@dual-bundle/import-meta-resolve": "^4.2.1", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^9.0.0", + "css-functions-list": "^3.2.3", + "css-tree": "^3.1.0", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^11.1.1", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^7.0.5", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.37.0", + "mathml-tag-names": "^2.1.3", + "meow": "^13.2.0", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.6", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.0", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "supports-hyperlinks": "^3.2.0", + "svg-tags": "^1.0.0", + "table": "^6.9.0", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/stylelint-config-recommended": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", + "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "stylelint": "^16.1.0" + } + }, + "node_modules/stylelint-config-standard": { + "version": "36.0.1", + "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz", + "integrity": "sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + }, + { + "type": "github", + "url": "https://github.com/sponsors/stylelint" + } + ], + "license": "MIT", + "dependencies": { + "stylelint-config-recommended": "^14.0.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "stylelint": "^16.1.0" + } + }, + "node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/stylelint/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/stylelint/node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/stylelint/node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", + "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.20" + } + }, + "node_modules/stylelint/node_modules/flat-cache": { + "version": "6.1.22", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", + "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.4", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true + }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/tauri-app/package.json b/tauri-app/package.json new file mode 100644 index 0000000..b9f7da3 --- /dev/null +++ b/tauri-app/package.json @@ -0,0 +1,61 @@ +{ + "name": "tauri-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage", + "gen:api": "openapi-typescript src/api/generated/openapi.json -o src/api/generated/schema.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", + "stylelint": "stylelint \"src/**/*.css\"", + "stylelint:fix": "stylelint \"src/**/*.css\" --fix" + }, + "dependencies": { + "@tanstack/react-virtual": "^3.13.23", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.0", + "@tauri-apps/plugin-fs": "^2.5.0", + "@tauri-apps/plugin-opener": "^2", + "assjs": "^0.1.5", + "immer": "^11.1.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.13.1", + "swr": "^2.4.1", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "jsdom": "^29.0.1", + "openapi-typescript": "^7.13.0", + "prettier": "^3.2.5", + "stylelint": "^16.2.1", + "stylelint-config-standard": "^36.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.58.0", + "vite": "^7.3.2", + "vitest": "^4.1.1" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + } +} diff --git a/tauri-app/public/assets/logo.png b/tauri-app/public/assets/logo.png new file mode 100644 index 0000000..78be1ff Binary files /dev/null and b/tauri-app/public/assets/logo.png differ diff --git a/tauri-app/public/audio/presets/ai_huangyaoshi_712.mp3 b/tauri-app/public/audio/presets/ai_huangyaoshi_712.mp3 new file mode 100644 index 0000000..453fe53 Binary files /dev/null and b/tauri-app/public/audio/presets/ai_huangyaoshi_712.mp3 differ diff --git a/tauri-app/public/audio/presets/ai_taiwan_man2_speech02.mp3 b/tauri-app/public/audio/presets/ai_taiwan_man2_speech02.mp3 new file mode 100644 index 0000000..dec9e95 Binary files /dev/null and b/tauri-app/public/audio/presets/ai_taiwan_man2_speech02.mp3 differ diff --git a/tauri-app/public/audio/presets/chat1_female_new-3.mp3 b/tauri-app/public/audio/presets/chat1_female_new-3.mp3 new file mode 100644 index 0000000..6602a1a Binary files /dev/null and b/tauri-app/public/audio/presets/chat1_female_new-3.mp3 differ diff --git a/tauri-app/public/audio/presets/chengshu_jiejie.mp3 b/tauri-app/public/audio/presets/chengshu_jiejie.mp3 new file mode 100644 index 0000000..1949e24 Binary files /dev/null and b/tauri-app/public/audio/presets/chengshu_jiejie.mp3 differ diff --git a/tauri-app/public/audio/presets/girlfriend_2_speech02.mp3 b/tauri-app/public/audio/presets/girlfriend_2_speech02.mp3 new file mode 100644 index 0000000..b4d887e Binary files /dev/null and b/tauri-app/public/audio/presets/girlfriend_2_speech02.mp3 differ diff --git a/tauri-app/public/audio/presets/tiexin_nanyou.mp3 b/tauri-app/public/audio/presets/tiexin_nanyou.mp3 new file mode 100644 index 0000000..2f2fa22 Binary files /dev/null and b/tauri-app/public/audio/presets/tiexin_nanyou.mp3 differ diff --git a/tauri-app/public/audio/presets/yizhipiannan-v1.mp3 b/tauri-app/public/audio/presets/yizhipiannan-v1.mp3 new file mode 100644 index 0000000..ab7dab4 Binary files /dev/null and b/tauri-app/public/audio/presets/yizhipiannan-v1.mp3 differ diff --git a/tauri-app/public/audio/presets/you_pingjing.mp3 b/tauri-app/public/audio/presets/you_pingjing.mp3 new file mode 100644 index 0000000..91265b2 Binary files /dev/null and b/tauri-app/public/audio/presets/you_pingjing.mp3 differ diff --git a/tauri-app/public/fonts/DouyinSansBold.ttf b/tauri-app/public/fonts/DouyinSansBold.ttf new file mode 120000 index 0000000..167b5ae --- /dev/null +++ b/tauri-app/public/fonts/DouyinSansBold.ttf @@ -0,0 +1 @@ +../../src-tauri/fonts/DouyinSansBold.ttf \ No newline at end of file diff --git a/tauri-app/public/tauri.svg b/tauri-app/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/tauri-app/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tauri-app/public/vite.svg b/tauri-app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/tauri-app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tauri-app/src-tauri/.gitignore b/tauri-app/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/tauri-app/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml new file mode 100644 index 0000000..4f0f1a5 --- /dev/null +++ b/tauri-app/src-tauri/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "tauri-app" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "tauri_app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["protocol-asset"] } +tauri-plugin-opener = "2" +tauri-plugin-shell = "2" +tauri-plugin-fs = "2" +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" +# HTTP 客户端: 精简功能 +# 使用 default-tls(默认)+ json,无需额外 features +reqwest = { version = "0.12", features = ["json"] } +uuid = { version = "1", features = ["v4"] } +# base64: 使用 0.22 最新版 +# 注意:Tauri 内部仍使用 0.21(通过 swift-rs),无法避免重复 +base64 = "0.22" +# rand 已被移除: 代码中未直接使用,uuid 内部自带随机数生成 +# chrono: 时间处理用于缓存索引 +chrono = { version = "0.4", features = ["serde"] } +# 结构化错误类型 +thiserror = "1" +# 文件锁(跨平台) +fs2 = "0.4" + diff --git a/tauri-app/src-tauri/DEPS_ANALYSIS.md b/tauri-app/src-tauri/DEPS_ANALYSIS.md new file mode 100644 index 0000000..c481409 --- /dev/null +++ b/tauri-app/src-tauri/DEPS_ANALYSIS.md @@ -0,0 +1,108 @@ +# Rust 依赖重复分析报告 + +## 📊 当前状态 + +- **重复条目数**: 54 +- **直接依赖**: 9 个 +- **传递依赖重复**: 45 个 + +## 🔴 需要关注的重复(版本冲突) + +| 依赖 | 版本 A | 版本 B | 影响 | 解决方案 | +|------|--------|--------|------|---------| +| **base64** | 0.21.7 | 0.22.1 | ⚠️ 中等 | Tauri 内部锁定 0.21,无法避免 | +| **bitflags** | 1.3.2 | 2.11.0 | ⚠️ 中等 | png/selectors 使用旧版,无法避免 | +| **thiserror** | 1.0.69 | 2.0.18 | ⚠️ 低 | 旧 crate 未升级,可等待更新 | +| **syn** | 1.0.109 | 2.0.117 | ⚠️ 低 | 过程宏依赖,编译时 only | +| **phf** | 0.8/0.10/0.11 | 多版本 | ⚠️ 低 | 深度传递依赖,无法避免 | +| **rand** | 0.7.3 | 0.8.5 | ⚠️ 低 | 旧 crate 依赖,无法避免 | +| **indexmap** | 1.9.3 | 2.13.0 | ⚠️ 低 | 旧 crate 依赖,无法避免 | + +## ✅ 同一版本多处引用(正常,非问题) + +以下依赖虽然显示"重复",但其实是**同一版本被多处引用**,不是真正的版本冲突: + +- `time v0.3.47` (2 处引用) +- `serde v1.0.228` (2 处引用) +- `serde_json v1.0.149` (2 处引用) +- `semver v1.0.27` (2 处引用) +- `smallvec v1.15.1` (2 处引用) +- `scopeguard v1.2.0` (2 处引用) +- `rand v0.8.5` (2 处引用) + +这些都是正常的依赖树结构,无需处理。 + +## 🔧 优化建议 + +### 1. 已完成的优化 + +- [x] 更新 Cargo.lock(`cargo update`) +- [x] 锁定 base64 版本(选择 0.22) + +### 2. 可考虑的优化 + +#### 方案 A: 使用 `[patch]` 强制统一(不推荐) + +```toml +[patch.crates-io] +# 强制所有依赖使用 base64 0.22 +base64 = "0.22" +``` + +⚠️ **风险**: 可能导致编译错误或运行时问题,因为 Tauri 内部代码是为 0.21 编写的。 + +#### 方案 B: 等待上游更新(推荐) + +- Tauri 未来版本可能会更新 swift-rs 到使用 base64 0.22 +- png crate 未来可能会升级到 bitflags 2.x + +#### 方案 C: 禁用未使用的功能(已实施) + +检查 `Cargo.toml`,确保启用的功能是最小化的: + +```toml +[dependencies] +# 只启用需要的功能 +reqwest = { version = "0.12", features = ["json"] } # ✅ 正确,只启用 json +``` + +## 📈 影响评估 + +| 指标 | 当前值 | 评估 | +|------|--------|------| +| 编译时间 | 正常 | 重复依赖对编译时间影响 < 5% | +| 二进制大小 | 正常 | base64 0.21 + 0.22 ≈ +50KB | +| 运行时性能 | 无影响 | 只是代码重复,运行时只使用一个版本 | +| 安全风险 | 低 | base64 和 bitflags 都是成熟库,两个版本都安全 | + +## 🎯 结论 + +**当前依赖状态**: ✅ **可接受** + +虽然存在 54 个"重复"条目,但: +1. 大部分是同一版本的多处引用(非真正重复) +2. 真正的版本冲突(如 base64 0.21/0.22)是传递依赖导致,无法避免 +3. 对编译时间和二进制大小影响很小 +4. 不影响运行时性能 + +**建议**: 保持现状,等待上游 crate 更新自然解决。 + +## 📝 维护命令 + +```bash +# 检查依赖重复 +cargo tree --duplicates + +# 更新依赖 +cargo update + +# 查看依赖树 +cargo tree + +# 检查是否有未使用的依赖 +cargo +nightly udeps # 需要安装 cargo-udeps +``` + +--- +*生成时间*: 2026-04-07 +*Cargo.toml 版本*: 0.1.0 diff --git a/tauri-app/src-tauri/DEPS_OPTIMIZATION.md b/tauri-app/src-tauri/DEPS_OPTIMIZATION.md new file mode 100644 index 0000000..570a5b5 --- /dev/null +++ b/tauri-app/src-tauri/DEPS_OPTIMIZATION.md @@ -0,0 +1,159 @@ +# Rust 依赖优化报告 + +## 📊 优化前后对比 + +### 直接依赖变更 + +| 依赖 | 优化前 | 优化后 | 变更 | +|------|--------|--------|------| +| rand | 0.8 | ❌ 移除 | 未使用 | +| base64 | 0.22 | 0.22 | 保留 | +| reqwest | 0.12 + default | 0.12 + json | 精简 | +| uuid | 1.x + v4 | 1.x + v4 | 保留 | + +**直接依赖从 9 个减少到 8 个** + +### 传递依赖影响 + +| 指标 | 优化前 | 预计优化后 | +|------|--------|-----------| +| 总 crate 数 | ~376 | ~360 (-16) | +| 编译单元 | 较多 | 减少 | +| 二进制大小 | 基准 | -50KB~100KB | + +--- + +## 🔍 详细分析 + +### 1. 已移除: `rand` + +**原因**: +```bash +$ grep -rn "rand::" src/ +# 无输出 - 代码中未直接使用 +``` + +`uuid` crate 内部已经包含了 v4 UUID 生成所需的随机数功能,无需额外依赖 `rand`。 + +**影响**: +- 移除 `rand` 及其传递依赖 (`rand_core`, `rand_chacha` 等) +- 减少约 5-10 个间接依赖 + +--- + +### 2. 检查: `reqwest` features + +当前配置: +```toml +reqwest = { version = "0.12", features = ["json"] } +``` + +Default features 包含: +- `default-tls` - 需要(HTTPS 支持) +- `charset` - 需要(编码处理) +- `http2` - 可能需要 +- `macos-system-configuration` - macOS 需要 + +**结论**: 当前配置已是最小化,无需进一步精简。 + +--- + +### 3. 无法避免的重复 + +以下重复由 Tauri 生态决定,**无法优化**: + +| 依赖 | 版本冲突 | 原因 | +|------|---------|------| +| base64 | 0.21 vs 0.22 | swift-rs (Tauri 内部) 锁定 0.21 | +| bitflags | 1.3 vs 2.11 | png/selectors 使用旧版 | +| phf | 0.8/0.10/0.11 | 深度传递依赖 | +| thiserror | 1.0 vs 2.0 | 新旧 crate 混用 | + +--- + +## 📈 性能影响 + +### 编译时间 +- **Debug**: 预计减少 5-10 秒 +- **Release**: 预计减少 10-20 秒 + +### 二进制大小 +- **预估减少**: 100-200KB +- **主要来源**: 移除 rand 相关代码 + +--- + +## ✅ 验证清单 + +```bash +# 1. 检查代码编译 +cargo check +cargo check --release + +# 2. 检查测试通过 +cargo test + +# 3. 检查依赖树 +cargo tree --duplicates | wc -l # 应该减少 + +# 4. 检查未使用依赖(可选) +cargo +nightly udeps +``` + +--- + +## 🎯 结论 + +### 是否达到最佳状态? + +**是的,当前已达到实际最佳状态。** + +理由: +1. ✅ 移除了未使用的 `rand` +2. ✅ `reqwest` 功能已最小化 +3. ✅ 其余依赖都是必需的 +4. ❌ 剩余重复是 Tauri 生态限制,无法控制 + +### 进一步优化途径(不推荐) + +1. **使用 `[patch]` 强制统一版本** + ```toml + [patch.crates-io] + base64 = "0.22" + ``` + ⚠️ 风险: 可能导致编译错误或运行时崩溃 + +2. **等待上游更新** + - Tauri 更新 swift-rs 到 base64 0.22 + - png crate 更新到 bitflags 2.x + - 这需要社区推动,无法控制 + +3. **Fork 并修改依赖** + ⚠️ 维护成本过高,不推荐 + +--- + +## 📝 维护建议 + +```bash +# 每月检查 +$ cargo update # 更新依赖 +$ cargo tree --duplicates # 检查重复 +$ cargo check --release # 验证编译 + +# 每季度审查 +$ cargo +nightly udeps # 检查未使用依赖 +``` + +--- + +## 🔗 相关链接 + +- [Cargo 依赖优化指南](https://doc.rust-lang.org/cargo/reference/profiles.html) +- [Tauri 依赖说明](https://tauri.app/v1/guides/building/app-size/) +- [Rust 二进制大小优化](https://github.com/johnthagen/min-sized-rust) + +--- + +*优化日期*: 2026-04-07 +*Cargo.toml 版本*: 0.1.0 (已优化) diff --git a/tauri-app/src-tauri/build.rs b/tauri-app/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/tauri-app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/tauri-app/src-tauri/capabilities/default.json b/tauri-app/src-tauri/capabilities/default.json new file mode 100644 index 0000000..96333ff --- /dev/null +++ b/tauri-app/src-tauri/capabilities/default.json @@ -0,0 +1,41 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$DOCUMENT/Meijiaka/**" }, + { "path": "$DOCUMENT/**" }, + { "path": "$APPLOCALDATA/**" }, + { "path": "$APPDATA/**" }, + { "path": "/**" } + ] + }, + "shell:default", + "shell:allow-spawn", + "fs:default", + "fs:allow-app-read-recursive", + "fs:allow-app-write-recursive", + { + "identifier": "fs:allow-read-file", + "allow": [ + { + "path": "$DOCUMENT/Meijiaka/**" + }, + { + "path": "$DOCUMENT/**" + }, + { + "path": "/**" + } + ] + }, + "dialog:default", + "dialog:allow-open" + ] +} diff --git a/tauri-app/src-tauri/fonts/DouyinSansBold.ttf b/tauri-app/src-tauri/fonts/DouyinSansBold.ttf new file mode 100644 index 0000000..eb8555c Binary files /dev/null and b/tauri-app/src-tauri/fonts/DouyinSansBold.ttf differ diff --git a/tauri-app/src-tauri/icons/128x128.png b/tauri-app/src-tauri/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/tauri-app/src-tauri/icons/128x128.png differ diff --git a/tauri-app/src-tauri/icons/128x128@2x.png b/tauri-app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/tauri-app/src-tauri/icons/128x128@2x.png differ diff --git a/tauri-app/src-tauri/icons/32x32.png b/tauri-app/src-tauri/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/tauri-app/src-tauri/icons/32x32.png differ diff --git a/tauri-app/src-tauri/icons/Square107x107Logo.png b/tauri-app/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..0ca4f27 Binary files /dev/null and b/tauri-app/src-tauri/icons/Square107x107Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square142x142Logo.png b/tauri-app/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..b81f820 Binary files /dev/null and b/tauri-app/src-tauri/icons/Square142x142Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square150x150Logo.png b/tauri-app/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..624c7bf Binary files /dev/null and b/tauri-app/src-tauri/icons/Square150x150Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square284x284Logo.png b/tauri-app/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..c021d2b Binary files /dev/null and b/tauri-app/src-tauri/icons/Square284x284Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square30x30Logo.png b/tauri-app/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..6219700 Binary files /dev/null and b/tauri-app/src-tauri/icons/Square30x30Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square310x310Logo.png b/tauri-app/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..f9bc048 Binary files /dev/null and b/tauri-app/src-tauri/icons/Square310x310Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square44x44Logo.png b/tauri-app/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..d5fbfb2 Binary files /dev/null and b/tauri-app/src-tauri/icons/Square44x44Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square71x71Logo.png b/tauri-app/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..63440d7 Binary files /dev/null and b/tauri-app/src-tauri/icons/Square71x71Logo.png differ diff --git a/tauri-app/src-tauri/icons/Square89x89Logo.png b/tauri-app/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..f3f705a Binary files /dev/null and b/tauri-app/src-tauri/icons/Square89x89Logo.png differ diff --git a/tauri-app/src-tauri/icons/StoreLogo.png b/tauri-app/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..4556388 Binary files /dev/null and b/tauri-app/src-tauri/icons/StoreLogo.png differ diff --git a/tauri-app/src-tauri/icons/icon.icns b/tauri-app/src-tauri/icons/icon.icns new file mode 100644 index 0000000..12a5bce Binary files /dev/null and b/tauri-app/src-tauri/icons/icon.icns differ diff --git a/tauri-app/src-tauri/icons/icon.ico b/tauri-app/src-tauri/icons/icon.ico new file mode 100644 index 0000000..b3636e4 Binary files /dev/null and b/tauri-app/src-tauri/icons/icon.ico differ diff --git a/tauri-app/src-tauri/icons/icon.png b/tauri-app/src-tauri/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/tauri-app/src-tauri/icons/icon.png differ diff --git a/tauri-app/src-tauri/src/api_proxy.rs b/tauri-app/src-tauri/src/api_proxy.rs new file mode 100644 index 0000000..17d66fd --- /dev/null +++ b/tauri-app/src-tauri/src/api_proxy.rs @@ -0,0 +1,114 @@ +//! Python API 代理模块 + +use serde::{Deserialize}; +use crate::utils; +use crate::ApiResponse; + +/// Python API 请求结构 +#[derive(Deserialize)] +pub struct ApiRequest { + pub module: String, + pub action: String, + pub payload: serde_json::Value, +} + +/// 路由配置: (module, action) -> (HTTP方法, API路径) +pub fn get_route(module: &str, action: &str) -> Option<(&'static str, &'static str)> { + match (module, action) { + // 脚本相关 + ("script", "generate") => Some(("POST", "/script/generate")), + ("script", "polish") => Some(("POST", "/script/polish")), + // 语音相关 + ("voice", "generateAudio") => Some(("POST", "/voice/generate")), + ("voice", "getVoiceLibrary") => Some(("GET", "/voice/library")), + // 视频相关 + ("video", "generateVideo") => Some(("POST", "/video/generate")), + ("video", "getDigitalHumans") => Some(("GET", "/video/library")), + _ => None, + } +} + +/// 通用 Python API 代理请求 +async fn proxy_to_python( + method: &str, + path: &str, + payload: serde_json::Value, + default_data: serde_json::Value, +) -> ApiResponse { + let url = format!("{}{}", super::PYTHON_API_BASE_URL, path); + let client = reqwest::Client::new(); + + let request = match method { + "GET" => client.get(&url), + "POST" => client.post(&url).json(&payload), + "PUT" => client.put(&url).json(&payload), + "DELETE" => client.delete(&url), + _ => client.post(&url).json(&payload), + }; + + let response = match request.send().await { + Ok(res) => res, + Err(e) => { + return ApiResponse { + code: 500, + message: format!("Failed to call Python API: {}", e), + data: None, + } + } + }; + + if response.status().is_success() { + let data: serde_json::Value = response.json().await.unwrap_or(default_data); + ApiResponse { + code: 200, + message: "success".to_string(), + data: Some(data), + } + } else { + let status = response.status(); + let mut error_message = format!("Python API returned error: {}", status); + if let Ok(text) = response.text().await { + if !text.is_empty() { + error_message = format!("{} - {}", error_message, text); + } + } + ApiResponse { + code: status.as_u16() as i32, + message: error_message, + data: None, + } + } +} + +/// 通用 API 调用入口(Tauri 命令) +#[tauri::command] +pub async fn api_call(app: tauri::AppHandle, req: ApiRequest) -> ApiResponse { + println!("API Proxy: module={}, action={}", req.module, req.action); + let _project_dir = utils::get_project_dir(&app); + + // 检查是否是路由表中定义的端点 + match get_route(&req.module, &req.action) { + Some((method, path)) => { + // 根据 action 确定默认返回值 + let default_data = match req.action.as_str() { + "getVoiceLibrary" | "getDigitalHumans" | "generate" => serde_json::json!([]), + _ => serde_json::json!({}), + }; + proxy_to_python(method, path, req.payload, default_data).await + } + None => { + // 未实现的端点 - 明确返回 Mock 标记 + println!("Warning: No handler for {}:{}, returning mock response", req.module, req.action); + ApiResponse { + code: 200, + message: format!("success (MOCK - {}:{} not implemented)", req.module, req.action), + data: Some(serde_json::json!({ + "_mock": true, + "_module": req.module, + "_action": req.action, + "_note": "This endpoint returns mock data for development" + })), + } + } + } +} diff --git a/tauri-app/src-tauri/src/auth.rs b/tauri-app/src-tauri/src/auth.rs new file mode 100644 index 0000000..c082df5 --- /dev/null +++ b/tauri-app/src-tauri/src/auth.rs @@ -0,0 +1,25 @@ +//! 认证相关命令 + +use serde_json; +use crate::ApiResponse; + +/// Mock 登录接口(开发用) +#[tauri::command] +pub fn auth_login(phone: String, code: String) -> ApiResponse { + println!("Auth Login: phone={}, code={}", phone, code); + // Mock successful login - 明确标记为 Mock 数据 + ApiResponse { + code: 200, + message: "success (MOCK login - no real authentication)".to_string(), + data: Some(serde_json::json!({ + "token": "mock_token_placeholder", + "user": { + "id": "mock_user_001", + "nickname": "创作达人", + "avatar": "" + }, + "_mock": true, + "_note": "This is mock authentication data for development only" + })), + } +} diff --git a/tauri-app/src-tauri/src/avatar_cache.rs b/tauri-app/src-tauri/src/avatar_cache.rs new file mode 100644 index 0000000..2437e1f --- /dev/null +++ b/tauri-app/src-tauri/src/avatar_cache.rs @@ -0,0 +1,110 @@ +//! 形象克隆本地缓存模块 +//! +//! 负责将远程视频和封面缓存到本地文件系统,加速卡片加载。 +//! 存储逻辑已迁移到 storage::cache,本模块保留命令入口和下载逻辑。 + +use tauri::AppHandle; +use reqwest::Client; +use base64::engine::general_purpose; +use base64::Engine as _; +use crate::storage::cache::CacheState; + +pub use crate::storage::cache::{CacheQueryResult, CacheSaveResult, CacheDeleteResult}; + +/// 查询指定 avatar 是否已有缓存 +#[tauri::command] +pub async fn query_avatar_cache( + app: AppHandle, + state: tauri::State<'_, CacheState>, + avatar_id: String, +) -> Result { + crate::storage::cache::query_avatar_cache(&app, &state, &avatar_id) + .map_err(|e| e.to_string()) +} + +/// 从远程 URL 下载视频并缓存到本地 +#[tauri::command] +pub async fn cache_avatar_video( + app: AppHandle, + state: tauri::State<'_, CacheState>, + avatar_id: String, + remote_url: String, +) -> Result { + let client = get_http_client(); + let response = client + .get(&remote_url) + .send() + .await + .map_err(|e| format!("Download failed: {}", e))?; + + // 检查 Content-Length,防止过大响应 + if let Some(content_length) = response.content_length() { + const MAX_SIZE: u64 = 200 * 1024 * 1024; // 200MB + if content_length > MAX_SIZE { + return Err(format!( + "Video too large: {} MB", + content_length / (1024 * 1024) + )); + } + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("Read failed: {}", e))?; + + crate::storage::cache::save_cached_video(&app, &state, &avatar_id, &remote_url, &bytes) + .map_err(|e| e.to_string()) +} + +/// 保存封面图片(base64 格式)到缓存 +#[tauri::command] +pub async fn save_avatar_poster( + app: AppHandle, + state: tauri::State<'_, CacheState>, + avatar_id: String, + base64_data: String, +) -> Result { + // 支持任意 data:[mime];base64, 前缀 + let base64_clean = base64_data + .strip_prefix("data:") + .and_then(|s| s.split_once(";base64,")) + .map(|(_, b64)| b64) + .unwrap_or(&base64_data); + + let decoded = general_purpose::STANDARD + .decode(base64_clean) + .map_err(|e| format!("Base64 decode failed: {}", e))?; + + crate::storage::cache::save_cached_poster(&app, &state, &avatar_id, &decoded) + .map_err(|e| e.to_string()) +} + +/// 删除指定 avatar 的缓存 +#[tauri::command] +pub async fn delete_avatar_cache( + app: AppHandle, + state: tauri::State<'_, CacheState>, + avatar_id: String, +) -> Result { + crate::storage::cache::delete_avatar_cache(&app, &state, &avatar_id) + .map_err(|e| e.to_string()) +} + +/// 获取缓存统计信息 +#[tauri::command] +pub async fn get_cache_stats( + app: AppHandle, + state: tauri::State<'_, CacheState>, +) -> Result { + crate::storage::cache::get_cache_stats(&app, &state) + .map_err(|e| e.to_string()) +} + +/// 获取或创建全局 HTTP 客户端 +fn get_http_client() -> Client { + Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .unwrap_or_else(|_| Client::new()) +} diff --git a/tauri-app/src-tauri/src/commands/asset.rs b/tauri-app/src-tauri/src/commands/asset.rs new file mode 100644 index 0000000..5cb6ef5 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/asset.rs @@ -0,0 +1,62 @@ +//! 项目资源存储命令 + +use crate::ApiResponse; +use crate::storage::project as project_storage; + +#[tauri::command] +pub async fn save_project_asset( + project_id: String, + filename: String, + base64_data: String, +) -> ApiResponse { + match project_storage::save_project_asset(&project_id, &filename, &base64_data).map_err(|e| e.to_string()) { + Ok(path) => ApiResponse { + code: 200, + message: "资源保存成功".to_string(), + data: Some(path), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("保存资源失败: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn get_video_save_path( + project_id: String, + filename: String, +) -> ApiResponse { + match project_storage::get_video_save_path(&project_id, &filename).map_err(|e| e.to_string()) { + Ok(path) => ApiResponse { + code: 200, + message: "获取路径成功".to_string(), + data: Some(path.to_string_lossy().to_string()), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("获取路径失败: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn get_image_save_path( + project_id: String, + filename: String, +) -> ApiResponse { + match project_storage::get_image_save_path(&project_id, &filename).map_err(|e| e.to_string()) { + Ok(path) => ApiResponse { + code: 200, + message: "获取路径成功".to_string(), + data: Some(path.to_string_lossy().to_string()), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("获取路径失败: {}", e), + data: None, + }, + } +} diff --git a/tauri-app/src-tauri/src/commands/auth_state.rs b/tauri-app/src-tauri/src/commands/auth_state.rs new file mode 100644 index 0000000..6ef6b50 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/auth_state.rs @@ -0,0 +1,55 @@ +//! 认证状态存储命令 + +use crate::ApiResponse; +use crate::storage::auth as auth_storage; + +#[tauri::command] +pub async fn load_auth_state(app: tauri::AppHandle) -> ApiResponse> { + match auth_storage::load_auth_state(&app).map_err(|e| e.to_string()) { + Ok(data) => ApiResponse { + code: 200, + message: "Auth state loaded successfully".to_string(), + data: Some(data), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to load auth state: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn save_auth_state( + app: tauri::AppHandle, + state: serde_json::Value, +) -> ApiResponse { + match auth_storage::save_auth_state(&app, &state).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Auth state saved successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to save auth state: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn clear_auth_state(app: tauri::AppHandle) -> ApiResponse { + match auth_storage::clear_auth_state(&app).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Auth state cleared successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to clear auth state: {}", e), + data: None, + }, + } +} diff --git a/tauri-app/src-tauri/src/commands/avatar.rs b/tauri-app/src-tauri/src/commands/avatar.rs new file mode 100644 index 0000000..d8b9a07 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/avatar.rs @@ -0,0 +1,39 @@ +//! 克隆形象列表存储命令 + +use crate::ApiResponse; +use crate::storage::avatar as avatar_storage; + +#[tauri::command] +pub async fn load_avatars_list(_app: tauri::AppHandle) -> ApiResponse> { + match avatar_storage::load_avatars_list().map_err(|e| e.to_string()) { + Ok(data) => ApiResponse { + code: 200, + message: "Avatars loaded successfully".to_string(), + data: Some(data), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to load avatars: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn save_avatars_list( + _app: tauri::AppHandle, + avatars: Vec, +) -> ApiResponse { + match avatar_storage::save_avatars_list(&avatars).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Avatars saved successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to save avatars: {}", e), + data: None, + }, + } +} diff --git a/tauri-app/src-tauri/src/commands/mod.rs b/tauri-app/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..dae93b7 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/mod.rs @@ -0,0 +1,11 @@ +//! Tauri 命令模块 +//! +//! 所有前端 IPC 调用入口按领域拆分,参数通过 Args 结构体接收。 +//! Args 结构体标注 `#[serde(rename_all = "camelCase")]` 以匹配前端 JSON 字段名, +//! 函数内部使用 snake_case 变量,消除 Rust `non_snake_case` 警告。 + +pub mod asset; +pub mod auth_state; +pub mod avatar; +pub mod product; +pub mod project; diff --git a/tauri-app/src-tauri/src/commands/product.rs b/tauri-app/src-tauri/src/commands/product.rs new file mode 100644 index 0000000..605e699 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/product.rs @@ -0,0 +1,295 @@ +//! 成品保存命令 + +use crate::ApiResponse; +use crate::storage; +use crate::storage::project as project_storage; +use crate::storage::get_products_dir; +use std::fs; +use serde::Serialize; +use std::path::PathBuf; +use tauri::AppHandle; + +#[derive(Serialize)] +pub struct ProductItem { + pub filename: String, + pub path: String, + pub created_at: u64, + pub file_size: u64, + pub poster_path: Option, +} + +#[tauri::command] +pub async fn save_final_product( + project_id: String, + source_path: String, + product_filename: String, +) -> ApiResponse { + match project_storage::save_final_product_to_products( + &project_id, + &source_path, + &product_filename, + ) { + Ok(target_path) => ApiResponse { + code: 200, + message: "成品保存成功".to_string(), + data: Some(target_path), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("保存成品失败: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn list_local_products(app: AppHandle) -> ApiResponse> { + match get_products_dir() { + Ok(products_dir) => { + let products_dir: PathBuf = products_dir; + if !products_dir.exists() { + return ApiResponse { + code: 200, + message: "成功".to_string(), + data: Some(vec![]), + }; + } + + let read_dir = match fs::read_dir(&products_dir) { + Ok(rd) => rd, + Err(_) => { + return ApiResponse { + code: 200, + message: "成功".to_string(), + data: Some(vec![]), + }; + } + }; + + let mut products = Vec::new(); + + for entry in read_dir { + let entry: fs::DirEntry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path: PathBuf = entry.path(); + if !path.is_file() { + continue; + } + + // 只处理视频文件 + let ext: Option = path.extension() + .and_then(|e: &std::ffi::OsStr| e.to_str()) + .map(|e: &str| e.to_lowercase()); + match ext { + Some(ext) if ["mp4", "mov", "avi", "mkv", "webm"].contains(&ext.as_str()) => {} + _ => continue, + } + + let filename: String = entry.file_name().to_string_lossy().to_string(); + let path_str: String = path.to_string_lossy().to_string(); + + let metadata: fs::Metadata = match entry.metadata() { + Ok(md) => md, + Err(_) => continue, + }; + + // 获取创建时间,如果失败则使用修改时间 + let created_at_sys: std::time::SystemTime = match metadata.created() { + Ok(ct) => ct, + Err(_) => match metadata.modified() { + Ok(mt) => mt, + Err(_) => std::time::SystemTime::UNIX_EPOCH, + } + }; + + let created_at: u64 = created_at_sys + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let file_size: u64 = metadata.len(); + + // 封面图路径:{products_dir}/{filename}.jpg + let poster_filename = format!("{}.jpg", filename); + let poster_path = products_dir.join(&poster_filename); + let poster_path_str: Option = if poster_path.exists() { + Some(poster_path.to_string_lossy().to_string()) + } else { + // 封面不存在,使用 FFmpeg 提取第一帧 + match crate::ffmpeg_cmd::extract_first_frame(&app, &path_str, poster_path.to_string_lossy().as_ref()).await { + Ok(_) => Some(poster_path.to_string_lossy().to_string()), + Err(_) => None, + } + }; + + products.push(ProductItem { + filename, + path: path_str, + created_at, + file_size, + poster_path: poster_path_str, + }); + } + + // 按创建时间倒序排序,最新的在前 + products.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + ApiResponse { + code: 200, + message: "成功".to_string(), + data: Some(products), + } + } + Err(e) => ApiResponse { + code: 500, + message: format!("获取成品列表失败: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn delete_local_product(filename: String) -> ApiResponse<()> { + match get_products_dir() { + Ok(products_dir) => { + let products_dir: PathBuf = products_dir; + let path: PathBuf = products_dir.join(filename); + if path.exists() && path.is_file() { + match fs::remove_file(path) { + Ok(_) => ApiResponse { + code: 200, + message: "删除成功".to_string(), + data: Some(()), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("删除失败: {}", e), + data: None, + }, + } + } else { + ApiResponse { + code: 404, + message: "文件不存在".to_string(), + data: None, + } + } + } + Err(e) => ApiResponse { + code: 500, + message: format!("删除成品失败: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn rename_local_product(old_filename: String, new_filename: String) -> ApiResponse<()> { + match get_products_dir() { + Ok(products_dir) => { + let products_dir: PathBuf = products_dir; + let old_path: PathBuf = products_dir.join(&old_filename); + let new_path: PathBuf = products_dir.join(&new_filename); + + if !old_path.exists() || !old_path.is_file() { + return ApiResponse { + code: 404, + message: "原文件不存在".to_string(), + data: None, + }; + } + + if new_path.exists() { + return ApiResponse { + code: 400, + message: "文件名已存在".to_string(), + data: None, + }; + } + + // 检查新文件名扩展名 + let old_ext = old_path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + let new_ext = new_path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + + // 如果新文件名没有扩展名,自动补全 + let final_new_path = if new_ext != old_ext && old_ext.is_some() { + new_path.with_extension(old_ext.unwrap()) + } else { + new_path + }; + + // 尝试从旧文件名提取 project_id(格式:final_proj_{project_id}_...) + if let Some(project_id) = extract_project_id_from_filename(&old_filename) { + // 更新项目元数据中的 finalVideoPath + if let Ok(project_dir) = storage::get_project_dir(&project_id) { + let meta_path = project_dir.join("meta.json"); + if meta_path.exists() { + let _ = storage::with_file_lock(&meta_path, || { + let mut meta = project_storage::load_project_meta(&project_id)?; + if let Some(obj) = meta.as_object_mut() { + obj.insert( + "finalVideoPath".to_string(), + serde_json::json!(final_new_path.to_string_lossy().to_string()) + ); + } + storage::atomic_write_json(&meta_path, &meta) + }); + } + } + } + + // 重命名视频文件 + match fs::rename(old_path, &final_new_path) { + Ok(_) => { + ApiResponse { + code: 200, + message: "重命名成功".to_string(), + data: Some(()), + } + }, + Err(e) => ApiResponse { + code: 500, + message: format!("重命名失败: {}", e), + data: None, + }, + } + } + Err(e) => ApiResponse { + code: 500, + message: format!("重命名成品失败: {}", e), + data: None, + }, + } +} + +/// 从成品文件名提取 project_id +/// 格式:final_proj_{project_id}_{random}_{timestamp}.mp4 +fn extract_project_id_from_filename(filename: &str) -> Option { + let without_ext = filename.rsplit('.').next().unwrap_or(filename); + + // 文件名以 "final_proj_" 开头 + if !without_ext.starts_with("final_proj_") { + return None; + } + + let parts: Vec<&str> = without_ext.split('_').collect(); + if parts.len() >= 3 { + // parts[0] = "final", parts[1] = "proj", parts[2] = project_id + // 但 project_id 本身可能包含下划线,所以需要不同的策略: + // "final_proj_" 长度是 11,之后第一个下划线之前就是 project_id + let after_prefix = &without_ext[11..]; + if let Some(next_underscore) = after_prefix.find('_') { + let project_id = &after_prefix[0..next_underscore]; + return Some(project_id.to_string()); + } + } + + None +} diff --git a/tauri-app/src-tauri/src/commands/project.rs b/tauri-app/src-tauri/src/commands/project.rs new file mode 100644 index 0000000..02b8d11 --- /dev/null +++ b/tauri-app/src-tauri/src/commands/project.rs @@ -0,0 +1,137 @@ +//! 项目存储命令 + +use crate::ApiResponse; +use crate::storage::project as project_storage; + +// ============================================================ +// 命令函数 +// ============================================================ + +#[tauri::command] +pub async fn save_project_meta(project_id: String, data: serde_json::Value) -> ApiResponse { + match project_storage::save_project_meta(&project_id, &data).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Project saved successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to save project: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn save_project_meta_raw(project_id: String, json_content: String) -> ApiResponse { + match project_storage::save_project_meta_raw(&project_id, &json_content).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Project saved successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to save project: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn load_project_meta(project_id: String) -> ApiResponse { + match project_storage::load_project_meta(&project_id).map_err(|e| e.to_string()) { + Ok(data) => ApiResponse { + code: 200, + message: "Project loaded successfully".to_string(), + data: Some(data), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to load project: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn save_project_segments(project_id: String, segments: serde_json::Value) -> ApiResponse { + match project_storage::save_project_segments(&project_id, &segments).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Segments saved successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to save segments: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn save_project_segments_raw(project_id: String, json_content: String) -> ApiResponse { + match project_storage::save_project_segments_raw(&project_id, &json_content).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Segments saved successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to save segments: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn load_project_segments(project_id: String) -> ApiResponse { + match project_storage::load_project_segments(&project_id).map_err(|e| e.to_string()) { + Ok(data) => ApiResponse { + code: 200, + message: "Segments loaded successfully".to_string(), + data: Some(data), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to load segments: {}", e), + data: None, + }, + } +} + +#[tauri::command] +pub async fn list_local_projects() -> ApiResponse> { + match project_storage::list_projects().map_err(|e| e.to_string()) { + Ok(projects) => ApiResponse { + code: 200, + message: "Projects listed successfully".to_string(), + data: Some(projects), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to list projects: {}", e), + data: Some(vec![]), + }, + } +} + +#[tauri::command] +pub async fn delete_local_project(project_id: String) -> ApiResponse { + match project_storage::delete_project(&project_id).map_err(|e| e.to_string()) { + Ok(_) => ApiResponse { + code: 200, + message: "Project deleted successfully".to_string(), + data: Some(true), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to delete project: {}", e), + data: None, + }, + } +} + diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs new file mode 100644 index 0000000..a697e4a --- /dev/null +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -0,0 +1,332 @@ +use tauri_plugin_shell::ShellExt; +use tauri_plugin_shell::process::CommandEvent; +use tauri::{AppHandle, Emitter}; + +/** + * 封装 FFmpeg Sidecar 调用 + */ +pub async fn run_ffmpeg(app: &AppHandle, args: Vec) -> Result { + let sidecar_command = app.shell().sidecar("ffmpeg") + .map_err(|e| format!("Failed to find ffmpeg sidecar: {}", e))?; + + let (mut rx, _child) = sidecar_command + .args(args) + .spawn() + .map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?; + + let mut output = String::new(); + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line) => { + output.push_str(&String::from_utf8_lossy(&line)); + } + CommandEvent::Stderr(line) => { + let log = String::from_utf8_lossy(&line); + println!("FFmpeg Log: {}", log); + + // 尝试解析进度: time=00:00:05.12 + if let Some(pos) = log.find("time=") { + let time_part = &log[pos + 5..].split_whitespace().next().unwrap_or(""); + if !time_part.is_empty() { + // 发送进度事件到前端 + let _ = app.emit("ffmpeg-progress", time_part); + } + } + } + CommandEvent::Terminated(status) => { + match status.code { + Some(0) => return Ok(output), + Some(code) => return Err(format!("FFmpeg exited with status {}", code)), + None => return Err("FFmpeg terminated by signal".to_string()), + } + } + _ => {} + } + } + Ok(output) +} + +/** + * 标准化单个视频片段 (调整为 1080:1920, 30fps, libx264, aac 44100Hz stereo) + */ +pub async fn standardize_video(app: &AppHandle, input_path: &str, output_path: &str) -> Result<(), String> { + let args = vec![ + "-i".to_string(), input_path.to_string(), + "-vf".to_string(), "fps=30,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(), + "-c:v".to_string(), "libx264".to_string(), + "-c:a".to_string(), "aac".to_string(), + "-ar".to_string(), "44100".to_string(), + "-ac".to_string(), "2".to_string(), + "-preset".to_string(), "veryfast".to_string(), + "-crf".to_string(), "23".to_string(), + "-r".to_string(), "30".to_string(), + "-y".to_string(), + output_path.to_string() + ]; + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** + * 拼接视频 - 快速模式 (要求编码/分辨率一致) + */ +pub async fn concat_videos_copy(app: &AppHandle, list_path: &str, output_path: &str) -> Result<(), String> { + let args = vec![ + "-f".to_string(), "concat".to_string(), + "-safe".to_string(), "0".to_string(), + "-i".to_string(), list_path.to_string(), + "-c".to_string(), "copy".to_string(), + "-y".to_string(), + output_path.to_string() + ]; + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** + * 拼接视频 - 兼容模式 (多步走:标准化 -> 拼接) + */ +pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec, output_path: &str) -> Result<(), String> { + let temp_dir = std::env::temp_dir(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + + let mut standardized_paths = Vec::new(); + + // 1. 标准化每个片段 + for (i, path) in video_paths.iter().enumerate() { + let std_path = temp_dir.join(format!("std_{}_{}.mp4", timestamp, i)); + standardize_video(app, path, std_path.to_str().unwrap()).await?; + standardized_paths.push(std_path); + } + + // 2. 生成 concat 列表 + let list_path = temp_dir.join(format!("concat_list_{}.txt", timestamp)); + let mut list_content = String::new(); + for path in &standardized_paths { + list_content.push_str(&format!("file '{}'\n", path.to_str().unwrap())); + } + std::fs::write(&list_path, list_content).map_err(|e| e.to_string())?; + + // 3. 执行快速拼接 + let concat_res = concat_videos_copy(app, list_path.to_str().unwrap(), output_path).await; + + // 4. 清理临时文件 + for path in standardized_paths { + let _ = std::fs::remove_file(path); + } + let _ = std::fs::remove_file(list_path); + + concat_res +} + +/** + * 音画合并 - 优化音质 + */ +pub async fn add_audio_to_video(app: &AppHandle, video_path: &str, audio_path: &str, output_path: &str) -> Result<(), String> { + let args = vec![ + "-i".to_string(), video_path.to_string(), + "-i".to_string(), audio_path.to_string(), + "-c:v".to_string(), "copy".to_string(), + "-c:a".to_string(), "aac".to_string(), + "-b:a".to_string(), "192k".to_string(), // 提高码率 + "-ar".to_string(), "44100".to_string(), // 统一采样率 + "-map".to_string(), "0:v:0".to_string(), + "-map".to_string(), "1:a:0".to_string(), + "-shortest".to_string(), + "-y".to_string(), + output_path.to_string() + ]; + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** + * 将封面图转换为一段短视频 (0.5s, 1080x1920, 30fps) + * 带静音音频轨道,避免 concat 时丢失后续片段音频 + */ +pub async fn create_cover_video(app: &AppHandle, input_path: &str, output_path: &str, duration: &str) -> Result<(), String> { + let args = vec![ + "-loop".to_string(), "1".to_string(), + "-i".to_string(), input_path.to_string(), + "-f".to_string(), "lavfi".to_string(), + "-i".to_string(), "anullsrc=r=44100:cl=stereo".to_string(), + "-c:v".to_string(), "libx264".to_string(), + "-c:a".to_string(), "aac".to_string(), + "-t".to_string(), duration.to_string(), + "-pix_fmt".to_string(), "yuv420p".to_string(), + "-vf".to_string(), "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1".to_string(), + "-r".to_string(), "30".to_string(), + "-shortest".to_string(), + "-y".to_string(), + output_path.to_string() + ]; + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** + * 获取应用资源目录下的字体路径 + * + * 开发模式下,如果资源目录找不到字体,回退到源码目录 src-tauri/fonts/ + */ +fn get_fonts_dir(app: &AppHandle) -> Result { + use tauri::Manager; + + // 先尝试从资源目录获取(生产模式) + if let Ok(resource_path) = app.path().resource_dir() { + let fonts_path = resource_path.join("fonts"); + println!("[get_fonts_dir] Checking resource path: {:?}", fonts_path); + if fonts_path.exists() { + println!("[get_fonts_dir] Found fonts at: {:?}", fonts_path); + return Ok(fonts_path); + } + } + + // 开发模式回退:从当前工作目录寻找源码中的 fonts 目录 + let cwd = std::env::current_dir() + .map_err(|e| format!("Failed to get cwd: {}", e))?; + + // cwd 通常是 src-tauri 目录,所以直接找 fonts/ + let dev_fonts_path = cwd.join("fonts"); + println!("[get_fonts_dir] Checking dev cwd path: {:?}", dev_fonts_path); + if dev_fonts_path.exists() { + println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path); + return Ok(dev_fonts_path); + } + + // 如果还是找不到,尝试往上一级找 + if let Some(parent) = cwd.parent() { + let dev_fonts_path_alt = parent.join("src-tauri/fonts"); + println!("[get_fonts_dir] Checking dev parent path: {:?}", dev_fonts_path_alt); + if dev_fonts_path_alt.exists() { + println!("[get_fonts_dir] Found fonts at: {:?}", dev_fonts_path_alt); + return Ok(dev_fonts_path_alt); + } + } + + // 尝试绝对路径 + let abs_path = std::path::Path::new("/Users/0fun/work/ai-meijiaka/tauri-app/src-tauri/fonts"); + if abs_path.exists() { + println!("[get_fonts_dir] Found fonts at absolute path: {:?}", abs_path); + return Ok(abs_path.to_path_buf()); + } + + Err("Could not find fonts directory in any location".to_string()) +} + +/** + * 提取视频首帧为图片 + */ +pub async fn extract_first_frame( + app: &AppHandle, + video_path: &str, + output_path: &str, +) -> Result<(), String> { + let args = vec![ + "-i".to_string(), video_path.to_string(), + "-ss".to_string(), "00:00:00".to_string(), + "-vframes".to_string(), "1".to_string(), + "-q:v".to_string(), "2".to_string(), + "-y".to_string(), + output_path.to_string(), + ]; + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** + * 压制 ASS 字幕到视频(使用嵌入字体) + * + * 使用抖音美好体 (DouyinSansBold) 作为默认字体 + */ +pub async fn burn_ass_subtitle( + app: &AppHandle, + video_path: &str, + ass_path: &str, + output_path: &str, +) -> Result<(), String> { + let fonts_dir = get_fonts_dir(app)?; + let fonts_dir_str = fonts_dir.to_str().ok_or("Invalid fonts dir path")?; + + // 转义路径中的特殊字符(FFmpeg 滤镜语法要求) + // 只需要转义冒号 : 因为它是滤镜参数分隔符 + // FFmpeg 中,整个引用路径不需要内部再转义单引号 + let ass_path_escaped = ass_path.replace(":", "\\:"); + let fonts_dir_escaped = fonts_dir_str.replace(":", "\\:"); + + // 使用 ass 滤镜,指定字体目录 + // 语法: ass='path':fontsdir='path' + let filter = format!("ass='{}':fontsdir='{}'", ass_path_escaped, fonts_dir_escaped); + + println!("FFmpeg filter: {}", filter); // 调试日志 + + let args = vec![ + "-i".to_string(), video_path.to_string(), + "-vf".to_string(), filter, + "-c:v".to_string(), "libx264".to_string(), + "-preset".to_string(), "medium".to_string(), + "-crf".to_string(), "23".to_string(), + "-c:a".to_string(), "copy".to_string(), // 音频直接复制 + "-y".to_string(), + output_path.to_string() + ]; + + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** 压制 ASS 字幕到视频(带自定义字体目录) + * 当前未使用,保留为扩展点。 + */ +#[allow(dead_code)] +pub async fn burn_ass_subtitle_with_fonts( + app: &AppHandle, + video_path: &str, + ass_path: &str, + fonts_dir: &str, + output_path: &str, +) -> Result<(), String> { + let filter = format!("ass='{}':fontsdir='{}'", ass_path, fonts_dir); + + let args = vec![ + "-i".to_string(), video_path.to_string(), + "-vf".to_string(), filter, + "-c:v".to_string(), "libx264".to_string(), + "-preset".to_string(), "medium".to_string(), + "-crf".to_string(), "23".to_string(), + "-c:a".to_string(), "copy".to_string(), + "-y".to_string(), + output_path.to_string() + ]; + + run_ffmpeg(app, args).await.map(|_| ()) +} + +/** + * 压制 ASS 字幕到图片(使用嵌入字体) + * + * 用于生成封面图 + */ +pub async fn burn_ass_subtitle_to_image( + app: &AppHandle, + image_path: &str, + ass_path: &str, + output_path: &str, +) -> Result<(), String> { + let fonts_dir = get_fonts_dir(app)?; + let fonts_dir_str = fonts_dir.to_str().ok_or("Invalid fonts dir path")?; + + let ass_path_escaped = ass_path.replace("'", "'\\''").replace(":", "\\:"); + let fonts_dir_escaped = fonts_dir_str.replace("'", "'\\''"); + + let filter = format!("ass='{}':fontsdir='{}'", ass_path_escaped, fonts_dir_escaped); + + let args = vec![ + "-loop".to_string(), "1".to_string(), + "-i".to_string(), image_path.to_string(), + "-vf".to_string(), filter, + "-t".to_string(), "1".to_string(), + "-frames:v".to_string(), "1".to_string(), + "-y".to_string(), + output_path.to_string() + ]; + + run_ffmpeg(app, args).await.map(|_| ()) +} diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs new file mode 100644 index 0000000..3ce1e39 --- /dev/null +++ b/tauri-app/src-tauri/src/lib.rs @@ -0,0 +1,264 @@ +//! 美家卡智影 Tauri 后端入口 +//! +//! 模块拆分: +//! - ffmpeg_cmd: FFmpeg 命令封装 +//! - persistence: 项目持久化存储 +//! - video_processing: 视频合成业务逻辑 +//! - api_proxy: Python API 代理 +//! - auth: 认证命令 +//! - utils: 通用工具函数 +//! - commands: Tauri IPC 命令入口(按领域拆分) + +mod ffmpeg_cmd; +mod video_processing; +mod api_proxy; +mod auth; +mod utils; +mod avatar_cache; +mod commands; +mod storage; + +use serde::{Deserialize, Serialize}; + +// ============================================================ +// 配置常量 +// ============================================================ + +/// Python API 基础 URL(后端服务地址,与 docker-compose.yml 中 api 服务的对外端口保持一致) +const PYTHON_API_BASE_URL: &str = "http://127.0.0.1:8080/api/v1"; + +// ============================================================ +// 公共类型导出 +// ============================================================ + +/// 前端透传请求结构体(当前未在 Rust 侧读取字段,保留用于未来扩展) +#[allow(dead_code)] +#[derive(Deserialize)] +pub struct ApiRequest { + module: String, + action: String, + payload: serde_json::Value, +} + +#[derive(Serialize)] +pub struct ApiResponse { + pub code: i32, + pub message: String, + pub data: Option, +} + +// ============================================================ +// 应用入口 +// ============================================================ + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + println!("DEBUG: Starting Tauri app with Superpowers..."); + println!("DEBUG: Python API base URL: {}", PYTHON_API_BASE_URL); + + use storage::cache::CacheState; + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .manage(CacheState::new()) + .invoke_handler(tauri::generate_handler![ + auth::auth_login, + api_proxy::api_call, + // 项目存储 + commands::project::save_project_meta, + commands::project::save_project_meta_raw, + commands::project::load_project_meta, + commands::project::save_project_segments, + commands::project::save_project_segments_raw, + commands::project::load_project_segments, + commands::project::list_local_projects, + commands::project::delete_local_project, + commands::avatar::load_avatars_list, + commands::avatar::save_avatars_list, + commands::auth_state::load_auth_state, + commands::auth_state::save_auth_state, + commands::auth_state::clear_auth_state, + // 头像缓存 + avatar_cache::query_avatar_cache, + avatar_cache::cache_avatar_video, + avatar_cache::save_avatar_poster, + avatar_cache::delete_avatar_cache, + avatar_cache::get_cache_stats, + // 字幕压制 + burn_subtitle, + // 视频首帧提取 + extract_video_first_frame, + // 封面图生成 + generate_cover_image, + // 保存项目资源(封面图片等) + commands::asset::save_project_asset, + // 获取视频保存路径 + commands::asset::get_video_save_path, + // 获取图片保存路径 + commands::asset::get_image_save_path, + // 视频合成 + video_composite_synthesis, + // 保存成品到 products 目录 + commands::product::save_final_product, + // 列出本地成品 + commands::product::list_local_products, + // 删除本地成品 + commands::product::delete_local_product, + // 重命名成品 + commands::product::rename_local_product, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} + +// ============================================================ +// 字幕压制命令 +// ============================================================ + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct BurnSubtitleRequest { + video_path: String, + ass_path: String, + output_path: String, +} + +#[tauri::command] +async fn burn_subtitle( + app: tauri::AppHandle, + request: BurnSubtitleRequest, +) -> ApiResponse { + match ffmpeg_cmd::burn_ass_subtitle( + &app, + &request.video_path, + &request.ass_path, + &request.output_path, + ).await { + Ok(_) => ApiResponse { + code: 200, + message: "Subtitle burned successfully".to_string(), + data: Some(request.output_path), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to burn subtitle: {}", e), + data: None, + }, + } +} + +// ============================================================ +// 封面生成命令 +// ============================================================ + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ExtractFirstFrameRequest { + video_path: String, + output_path: String, +} + +#[tauri::command] +async fn extract_video_first_frame( + app: tauri::AppHandle, + request: ExtractFirstFrameRequest, +) -> ApiResponse { + match ffmpeg_cmd::extract_first_frame( + &app, + &request.video_path, + &request.output_path, + ).await { + Ok(_) => ApiResponse { + code: 200, + message: "First frame extracted successfully".to_string(), + data: Some(request.output_path), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to extract first frame: {}", e), + data: None, + }, + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GenerateCoverImageRequest { + image_path: String, + ass_path: String, + output_path: String, +} + +#[tauri::command] +async fn generate_cover_image( + app: tauri::AppHandle, + request: GenerateCoverImageRequest, +) -> ApiResponse { + match ffmpeg_cmd::burn_ass_subtitle_to_image( + &app, + &request.image_path, + &request.ass_path, + &request.output_path, + ).await { + Ok(_) => ApiResponse { + code: 200, + message: "Cover image generated successfully".to_string(), + data: Some(request.output_path), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("Failed to generate cover image: {}", e), + data: None, + }, + } +} + +// ============================================================ +// 视频合成命令 +// ============================================================ + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct VideoCompositeRequest { + video_paths: Vec, + audio_path: Option, + cover_path: Option, + output_path: String, +} + +#[tauri::command] +async fn video_composite_synthesis( + app: tauri::AppHandle, + request: VideoCompositeRequest, +) -> ApiResponse { + let project_dir = utils::get_project_dir(&app); + let payload = serde_json::json!({ + "videoPaths": request.video_paths, + "audioPath": request.audio_path.unwrap_or_default(), + "coverPath": request.cover_path, + "outputPath": request.output_path, + }); + let response = video_processing::handle_video_synthesis(&app, &project_dir, payload).await; + if response.code == 200 { + if let Some(data) = response.data { + if let Some(path) = data.as_object() + .and_then(|obj| obj.get("outputPath")) + .and_then(|v| v.as_str()) + { + return ApiResponse { + code: 200, + message: "视频合成成功".to_string(), + data: Some(path.to_string()), + }; + } + } + } + ApiResponse { + code: response.code, + message: response.message, + data: None, + } +} diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs new file mode 100644 index 0000000..2abccd9 --- /dev/null +++ b/tauri-app/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri_app_lib::run() +} diff --git a/tauri-app/src-tauri/src/storage/auth.rs b/tauri-app/src-tauri/src/storage/auth.rs new file mode 100644 index 0000000..7816f52 --- /dev/null +++ b/tauri-app/src-tauri/src/storage/auth.rs @@ -0,0 +1,45 @@ +//! 认证状态存储 +//! +//! 负责 auth.json 的读写,使用原子写入 + 文件锁 + Unix 0600 权限。 + +use serde::Serialize; +use serde::de::DeserializeOwned; +use tauri::AppHandle; +use crate::storage::engine::{ + atomic_write_json, with_file_lock, read_json, + restrict_file_permissions, StorageError, +}; +use crate::storage::paths::get_auth_state_path; + +/// 保存认证状态 +pub fn save_auth_state(app: &AppHandle, state: &T) -> Result<(), StorageError> +where + T: Serialize, +{ + let path = get_auth_state_path(app)?; + with_file_lock(&path, || { + atomic_write_json(&path, state)?; + restrict_file_permissions(&path)?; + Ok(()) + }) +} + +/// 加载认证状态 +pub fn load_auth_state(app: &AppHandle) -> Result, StorageError> +where + T: DeserializeOwned, +{ + let path = get_auth_state_path(app)?; + read_json(&path) +} + +/// 清除认证状态 +pub fn clear_auth_state(app: &AppHandle) -> Result<(), StorageError> { + let path = get_auth_state_path(app)?; + // 直接尝试删除,忽略 NotFound 错误 + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} diff --git a/tauri-app/src-tauri/src/storage/avatar.rs b/tauri-app/src-tauri/src/storage/avatar.rs new file mode 100644 index 0000000..1e7875e --- /dev/null +++ b/tauri-app/src-tauri/src/storage/avatar.rs @@ -0,0 +1,26 @@ +//! 克隆形象列表存储 +//! +//! 负责 avatars.json 的读写,使用原子写入 + 文件锁。 + +use serde_json::Value; +use crate::storage::engine::{ + atomic_write_json, with_file_lock, read_json, StorageError, +}; +use crate::storage::paths::get_avatars_json_path; + +/// 保存头像列表 +pub fn save_avatars_list(avatars: &Vec) -> Result<(), StorageError> { + let path = get_avatars_json_path()?; + with_file_lock(&path, || { + atomic_write_json(&path, avatars) + }) +} + +/// 加载头像列表 +pub fn load_avatars_list() -> Result, StorageError> { + let path = get_avatars_json_path()?; + match read_json(&path)? { + Some(data) => Ok(data), + None => Ok(vec![]), + } +} diff --git a/tauri-app/src-tauri/src/storage/cache.rs b/tauri-app/src-tauri/src/storage/cache.rs new file mode 100644 index 0000000..ad51b49 --- /dev/null +++ b/tauri-app/src-tauri/src/storage/cache.rs @@ -0,0 +1,451 @@ +//! 头像缓存存储层 +//! +//! 负责缓存索引(index.json)和视频/封面文件的读写。 +//! 索引操作受 Mutex 保护,文件写入使用原子写入。 + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use crate::storage::engine::{ + atomic_write_json, atomic_write_bytes, read_json, + sanitize_id, StorageError, +}; +use crate::storage::paths::{ + get_cache_root, get_cache_videos_dir, get_cache_posters_dir, +}; + +// ============================================================ +// 常量 +// ============================================================ + +/// 最大缓存大小:500MB +const MAX_CACHE_SIZE_BYTES: u64 = 500 * 1024 * 1024; +/// 清理阈值:达到上限的 80% 时开始清理 +const CACHE_CLEAN_THRESHOLD: f64 = 0.8; + +// ============================================================ +// 数据结构 +// ============================================================ + +/// 缓存索引状态(受 Mutex 保护) +pub struct CacheState { + pub index: Mutex, + pub loaded: std::sync::atomic::AtomicBool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AvatarCacheIndex { + pub version: u32, + pub cached_avatars: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvatarCacheEntry { + pub avatar_id: String, + pub remote_url: String, + pub video_path: String, // 相对路径 + pub poster_path: Option, // 相对路径 + pub cached_at: String, // ISO 8601 + pub file_size: u64, // 字节 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheQueryResult { + pub cached: bool, + pub local_video_path: Option, + pub local_poster_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheSaveResult { + pub success: bool, + pub local_path: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheDeleteResult { + pub success: bool, + pub message: Option, +} + +// ============================================================ +// 索引操作 +// ============================================================ + +impl CacheState { + pub fn new() -> Self { + Self { + index: Mutex::new(AvatarCacheIndex::default()), + loaded: std::sync::atomic::AtomicBool::new(false), + } + } + + /// 确保索引已从磁盘加载(幂等) + pub fn ensure_loaded(&self, app: &AppHandle) -> Result<(), StorageError> { + if self.loaded.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(()); + } + self.load_from_disk(app)?; + self.loaded.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + /// 从磁盘加载索引到 Mutex(内部使用) + fn load_from_disk(&self, app: &AppHandle) -> Result<(), StorageError> { + let root = get_cache_root(app)?; + let index_path = root.join("index.json"); + let index = match read_json::(&index_path)? { + Some(idx) => idx, + None => AvatarCacheIndex::default(), + }; + let mut guard = self.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + *guard = index; + Ok(()) + } + + /// 保存索引到磁盘 + pub fn save_to_disk(&self, app: &AppHandle) -> Result<(), StorageError> { + let root = get_cache_root(app)?; + let index_path = root.join("index.json"); + let index = self.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + atomic_write_json(&index_path, &*index) + } +} + +// ============================================================ +// 查询 +// ============================================================ + +/// 查询指定 avatar 的缓存状态 +pub fn query_avatar_cache( + app: &AppHandle, + state: &CacheState, + avatar_id: &str, +) -> Result { + state.ensure_loaded(app)?; + let safe_id = sanitize_id(avatar_id)?; + let root = get_cache_root(app)?; + + let mut index = state.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + + let entry = match index.cached_avatars.get(&safe_id) { + Some(e) => e, + None => { + return Ok(CacheQueryResult { + cached: false, + local_video_path: None, + local_poster_path: None, + }); + } + }; + + let video_path = root.join(&entry.video_path); + if !video_path.exists() { + // 文件不存在,清理索引(不保存,由调用者决定何时保存) + index.cached_avatars.remove(&safe_id); + return Ok(CacheQueryResult { + cached: false, + local_video_path: None, + local_poster_path: None, + }); + } + + let local_video_path = Some(video_path.to_string_lossy().to_string()); + let local_poster_path = entry.poster_path.as_ref().map(|p| { + root.join(p).to_string_lossy().to_string() + }); + + Ok(CacheQueryResult { + cached: true, + local_video_path, + local_poster_path, + }) +} + +// ============================================================ +// 保存视频 +// ============================================================ + +/// 保存视频到缓存 +/// +/// 调用者需先下载好字节数据,此函数负责写入文件和更新索引。 +pub fn save_cached_video( + app: &AppHandle, + state: &CacheState, + avatar_id: &str, + remote_url: &str, + data: &[u8], +) -> Result { + let safe_id = sanitize_id(avatar_id)?; + let root = get_cache_root(app)?; + let videos_dir = get_cache_videos_dir(app)?; + + let video_relative = format!("videos/{}.mp4", safe_id); + let video_absolute = videos_dir.join(format!("{}.mp4", safe_id)); + + // 1. 清理(如果需要) + { + let mut index = state.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + cleanup_if_needed(app, &root, &mut index)?; + } + + // 2. 写入文件(原子写入) + atomic_write_bytes(&video_absolute, data)?; + let file_size = data.len() as u64; + + // 3. 更新索引 + { + let mut index = state.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + + let entry = AvatarCacheEntry { + avatar_id: safe_id.clone(), + remote_url: remote_url.to_string(), + video_path: video_relative, + poster_path: None, + cached_at: chrono::Utc::now().to_rfc3339(), + file_size, + }; + index.cached_avatars.insert(safe_id, entry); + } + + // 4. 保存索引 + state.save_to_disk(app)?; + + Ok(CacheSaveResult { + success: true, + local_path: Some(video_absolute.to_string_lossy().to_string()), + message: None, + }) +} + +// ============================================================ +// 保存封面 +// ============================================================ + +/// 保存封面到缓存 +pub fn save_cached_poster( + app: &AppHandle, + state: &CacheState, + avatar_id: &str, + data: &[u8], +) -> Result { + state.ensure_loaded(app)?; + let safe_id = sanitize_id(avatar_id)?; + let posters_dir = get_cache_posters_dir(app)?; + let poster_relative = format!("posters/{}.jpg", safe_id); + let poster_absolute = posters_dir.join(format!("{}.jpg", safe_id)); + + // 写入文件 + atomic_write_bytes(&poster_absolute, data)?; + + // 更新索引 + { + let mut index = state.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + + if let Some(entry) = index.cached_avatars.get_mut(&safe_id) { + entry.poster_path = Some(poster_relative); + } + // 如果 avatar 不在索引中,不记录 poster(避免孤儿文件无索引记录) + } + + state.save_to_disk(app)?; + + Ok(CacheSaveResult { + success: true, + local_path: Some(poster_absolute.to_string_lossy().to_string()), + message: None, + }) +} + +// ============================================================ +// 删除 +// ============================================================ + +/// 删除指定 avatar 的缓存 +pub fn delete_avatar_cache( + app: &AppHandle, + state: &CacheState, + avatar_id: &str, +) -> Result { + state.ensure_loaded(app)?; + let safe_id = sanitize_id(avatar_id)?; + let root = get_cache_root(app)?; + + let mut index = state.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + + let entry = match index.cached_avatars.remove(&safe_id) { + Some(e) => e, + None => { + return Ok(CacheDeleteResult { + success: true, + message: Some("Entry not found in cache index".to_string()), + }); + } + }; + + // 删除视频文件 + let video_path = root.join(&entry.video_path); + if video_path.exists() { + if let Err(e) = fs::remove_file(&video_path) { + eprintln!("[cache] Failed to remove video file: {}", e); + } + } + + // 删除封面文件 + if let Some(poster_path) = &entry.poster_path { + let poster_abs = root.join(poster_path); + if poster_abs.exists() { + if let Err(e) = fs::remove_file(&poster_abs) { + eprintln!("[cache] Failed to remove poster file: {}", e); + } + } + } + + // 保存索引 + drop(index); // 先释放锁,save_to_disk 会重新获取 + state.save_to_disk(app)?; + + Ok(CacheDeleteResult { + success: true, + message: None, + }) +} + +// ============================================================ +// 统计 +// ============================================================ + +/// 获取缓存统计信息 +pub fn get_cache_stats( + app: &AppHandle, + state: &CacheState, +) -> Result { + state.ensure_loaded(app)?; + let index = state.index.lock().map_err(|_| { + StorageError::LockFailed("index mutex poisoned".into()) + })?; + + let total_count = index.cached_avatars.len(); + let total_size: u64 = calculate_actual_cache_size(app, &index)?; + let usage_percent = (total_size as f64 / MAX_CACHE_SIZE_BYTES as f64 * 100.0).round(); + + Ok(serde_json::json!({ + "code": 200, + "data": { + "count": total_count, + "total_size_bytes": total_size, + "total_size_mb": (total_size as f64 / (1024.0 * 1024.0)).round(), + "max_size_mb": MAX_CACHE_SIZE_BYTES / (1024 * 1024), + "usage_percent": usage_percent, + "threshold_percent": (CACHE_CLEAN_THRESHOLD * 100.0) as u32 + } + })) +} + +// ============================================================ +// 内部辅助 +// ============================================================ + +/// 基于实际文件大小计算缓存总大小 +fn calculate_actual_cache_size( + app: &AppHandle, + index: &AvatarCacheIndex, +) -> Result { + let root = get_cache_root(app)?; + let mut total = 0u64; + for entry in index.cached_avatars.values() { + let video_path = root.join(&entry.video_path); + if video_path.exists() { + if let Ok(meta) = fs::metadata(&video_path) { + total += meta.len(); + } + } + if let Some(poster_path) = &entry.poster_path { + let poster_abs = root.join(poster_path); + if poster_abs.exists() { + if let Ok(meta) = fs::metadata(&poster_abs) { + total += meta.len(); + } + } + } + } + Ok(total) +} + +/// 如果缓存超过阈值,清理最旧的条目 +fn cleanup_if_needed( + app: &AppHandle, + root: &PathBuf, + index: &mut AvatarCacheIndex, +) -> Result<(), StorageError> { + let actual_size = calculate_actual_cache_size(app, index)?; + let target_size = (MAX_CACHE_SIZE_BYTES as f64 * CACHE_CLEAN_THRESHOLD) as u64; + + if actual_size <= target_size { + return Ok(()); + } + + let mut current_size = actual_size; + let mut to_remove = Vec::new(); + + // 按缓存时间排序(最旧的在前) + let mut entries: Vec<_> = index.cached_avatars.iter().collect(); + entries.sort_by(|a, b| a.1.cached_at.cmp(&b.1.cached_at)); + + for (avatar_id, entry) in entries { + if current_size <= target_size { + break; + } + + // 删除视频 + let video_path = root.join(&entry.video_path); + if video_path.exists() { + if let Ok(meta) = fs::metadata(&video_path) { + let _ = fs::remove_file(&video_path); + current_size -= meta.len(); + } + } + + // 删除封面 + if let Some(poster_path) = &entry.poster_path { + let poster_abs = root.join(poster_path); + if poster_abs.exists() { + if let Ok(meta) = fs::metadata(&poster_abs) { + let _ = fs::remove_file(&poster_abs); + current_size -= meta.len(); + } + } + } + + to_remove.push(avatar_id.clone()); + } + + for avatar_id in to_remove { + index.cached_avatars.remove(&avatar_id); + } + + println!( + "[cache] Cleanup complete, current size: {} MB", + current_size / (1024 * 1024) + ); + + Ok(()) +} diff --git a/tauri-app/src-tauri/src/storage/engine.rs b/tauri-app/src-tauri/src/storage/engine.rs new file mode 100644 index 0000000..8964eac --- /dev/null +++ b/tauri-app/src-tauri/src/storage/engine.rs @@ -0,0 +1,169 @@ +//! Storage Engine 核心 +//! +//! 所有本地文件操作的基础能力: +//! - sanitize_*: 路径净化,防御路径遍历 +//! - atomic_write_*: 原子写入,防崩溃截断 +//! - with_file_lock: 文件锁,防并发竞态 +//! - read_json: 安全读取,文件不存在返回 None + +use std::fs; +use std::path::Path; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; + +/// 存储层统一错误类型 +#[derive(Debug, Error)] +pub enum StorageError { + #[error("Invalid identifier: {0}")] + InvalidId(String), + #[error("Path traversal detected")] + PathTraversal, + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + #[error("Lock acquisition failed: {0}")] + LockFailed(String), +} + +/// 将 StorageError 转换为 String +impl From for String { + fn from(err: StorageError) -> Self { + err.to_string() + } +} + +/// ID 白名单校验 +/// +/// 只允许字母、数字、下划线、连字符。 +/// 用于 project_id, avatar_id 等标识符。 +pub fn sanitize_id(id: &str) -> Result { + if id.is_empty() || id.len() > 64 { + return Err(StorageError::InvalidId( + "empty or too long".into(), + )); + } + if !id.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { + return Err(StorageError::InvalidId( + "contains illegal characters".into(), + )); + } + Ok(id.to_string()) +} + +/// 文件名净化 +/// +/// 提取纯文件名部分,拒绝任何目录组件(如 `../../etc/passwd`)。 +/// 返回净化后的文件名字符串。 +pub fn sanitize_filename(name: &str) -> Result { + let path = Path::new(name); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or(StorageError::PathTraversal)?; + if file_name.is_empty() || file_name.len() > 255 { + return Err(StorageError::InvalidId( + "invalid filename".into(), + )); + } + Ok(file_name.to_string()) +} + +/// 原子写入 JSON 文件 +/// +/// 先写入 `.tmp` 临时文件,再通过 `fs::rename` 原子替换目标文件。 +/// 确保崩溃或断电时不会留下半写文件。 +pub fn atomic_write_json( + path: &Path, + value: &impl Serialize, +) -> Result<(), StorageError> { + let tmp = path.with_extension("tmp"); + let content = serde_json::to_string_pretty(value)?; + fs::write(&tmp, content)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +/// 原子写入二进制文件 +/// +/// 同 `atomic_write_json`,但写入原始字节。 +pub fn atomic_write_bytes( + path: &Path, + data: &[u8], +) -> Result<(), StorageError> { + let tmp = path.with_extension("tmp"); + fs::write(&tmp, data)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +/// 在文件锁保护下执行操作 +/// +/// 对目标文件创建 `.lock` 锁文件,加独占锁后执行闭包, +/// 完成后自动释放锁并清理锁文件。 +/// +/// 注意:锁粒度为单个文件,不同文件之间互不阻塞。 +pub fn with_file_lock( + path: &Path, + f: impl FnOnce() -> Result, +) -> Result { + let lock_path = path.with_extension("lock"); + let file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&lock_path) + .map_err(|e| StorageError::LockFailed(e.to_string()))?; + + fs2::FileExt::lock_exclusive(&file) + .map_err(|e| StorageError::LockFailed(e.to_string()))?; + + let result = f(); + + let _ = fs2::FileExt::unlock(&file); + let _ = fs::remove_file(&lock_path); + + result +} + +/// 安全读取 JSON 文件 +/// +/// - 文件不存在 → 返回 `Ok(None)` +/// - 解析失败 → 返回 `Err(StorageError::Serialization)` +/// - 成功 → 返回 `Ok(Some(T))` +pub fn read_json( + path: &Path, +) -> Result, StorageError> { + match fs::read_to_string(path) { + Ok(content) => { + let value = serde_json::from_str(&content)?; + Ok(Some(value)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// 确保目录存在 +/// +/// 幂等操作,多线程并发调用安全。 +pub fn ensure_dir(path: &Path) -> Result<(), StorageError> { + if !path.exists() { + fs::create_dir_all(path)?; + } + Ok(()) +} + +/// 设置 Unix 文件权限为 0600(仅所有者可读写) +/// +/// 非 Unix 平台上为无操作。 +pub fn restrict_file_permissions(path: &Path) -> Result<(), StorageError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} diff --git a/tauri-app/src-tauri/src/storage/mod.rs b/tauri-app/src-tauri/src/storage/mod.rs new file mode 100644 index 0000000..a02e7ee --- /dev/null +++ b/tauri-app/src-tauri/src/storage/mod.rs @@ -0,0 +1,22 @@ +//! Storage Engine — 本地文件存储统一抽象层 +//! +//! 提供路径净化、原子写入、文件锁三大核心能力, +//! 所有本地 JSON/二进制文件的读写必须通过此层。 + +mod engine; +mod paths; + +pub mod project; +pub mod avatar; +pub mod auth; +pub mod cache; + +pub use engine::{ + atomic_write_json, atomic_write_bytes, + with_file_lock, read_json, StorageError, +}; + +pub use paths::{ + get_project_dir, get_project_assets_dir, + get_products_dir, get_cache_root, +}; diff --git a/tauri-app/src-tauri/src/storage/paths.rs b/tauri-app/src-tauri/src/storage/paths.rs new file mode 100644 index 0000000..4357ea9 --- /dev/null +++ b/tauri-app/src-tauri/src/storage/paths.rs @@ -0,0 +1,121 @@ +//! 路径计算集中管理 +//! +//! 所有本地存储路径的计算逻辑统一放在此处,禁止在业务代码中硬编码路径。 +//! 所有返回 PathBuf 的函数内部已做路径净化。 + +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; +use crate::storage::engine::{sanitize_id, StorageError}; + +/// 获取美家卡用户文档目录 +/// ~/Documents/Meijiaka/ +pub fn get_meijiaka_dir() -> Result { + let base = dirs::document_dir() + .ok_or_else(|| StorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "无法获取文档目录", + )))?; + Ok(base.join("Meijiaka")) +} + +/// 获取项目目录路径(不自动创建) +/// ~/Documents/Meijiaka/projects/{project_id}/ +pub fn get_project_dir_path(project_id: &str) -> Result { + let safe_id = sanitize_id(project_id)?; + let base = get_meijiaka_dir()?; + Ok(base.join("projects").join(&safe_id)) +} + +/// 获取项目目录(自动创建) +/// ~/Documents/Meijiaka/projects/{project_id}/ +pub fn get_project_dir(project_id: &str) -> Result { + let path = get_project_dir_path(project_id)?; + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} + +/// 获取项目内的 assets 目录 +/// ~/Documents/Meijiaka/projects/{project_id}/assets/ +pub fn get_project_assets_dir(project_id: &str) -> Result { + let path = get_project_dir(project_id)?; + let assets = path.join("assets"); + crate::storage::engine::ensure_dir(&assets)?; + Ok(assets) +} + +/// 获取项目内的 videos 目录 +/// ~/Documents/Meijiaka/projects/{project_id}/videos/ +pub fn get_project_videos_dir(project_id: &str) -> Result { + let path = get_project_dir(project_id)?; + let videos = path.join("videos"); + crate::storage::engine::ensure_dir(&videos)?; + Ok(videos) +} + +/// 获取项目内的 images 目录 +/// ~/Documents/Meijiaka/projects/{project_id}/images/ +pub fn get_project_images_dir(project_id: &str) -> Result { + let path = get_project_dir(project_id)?; + let images = path.join("images"); + crate::storage::engine::ensure_dir(&images)?; + Ok(images) +} + +/// 获取 products 目录 +/// ~/Documents/Meijiaka/products/ +pub fn get_products_dir() -> Result { + let base = get_meijiaka_dir()?; + let path = base.join("products"); + crate::storage::engine::ensure_dir(&path)?; + Ok(path) +} + +/// 获取头像列表 JSON 路径 +/// ~/Documents/Meijiaka/avatars.json +pub fn get_avatars_json_path() -> Result { + let base = get_meijiaka_dir()?; + Ok(base.join("avatars.json")) +} + +/// 获取认证状态文件路径 +/// {app_config_dir}/auth.json +pub fn get_auth_state_path(app: &AppHandle) -> Result { + let path = app.path().app_config_dir() + .map_err(|e| StorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("获取应用配置目录失败: {}", e), + )))?; + crate::storage::engine::ensure_dir(&path)?; + Ok(path.join("auth.json")) +} + +/// 获取头像缓存根目录 +/// {app_data_dir}/avatars/ +pub fn get_cache_root(app: &AppHandle) -> Result { + let app_data = app.path().app_data_dir() + .map_err(|e| StorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("获取应用数据目录失败: {}", e), + )))?; + let root = app_data.join("avatars"); + crate::storage::engine::ensure_dir(&root)?; + Ok(root) +} + +/// 获取头像缓存视频目录 +/// {app_data_dir}/avatars/videos/ +pub fn get_cache_videos_dir(app: &AppHandle) -> Result { + let root = get_cache_root(app)?; + let dir = root.join("videos"); + crate::storage::engine::ensure_dir(&dir)?; + Ok(dir) +} + +/// 获取头像缓存封面目录 +/// {app_data_dir}/avatars/posters/ +pub fn get_cache_posters_dir(app: &AppHandle) -> Result { + let root = get_cache_root(app)?; + let dir = root.join("posters"); + crate::storage::engine::ensure_dir(&dir)?; + Ok(dir) +} diff --git a/tauri-app/src-tauri/src/storage/project.rs b/tauri-app/src-tauri/src/storage/project.rs new file mode 100644 index 0000000..582f734 --- /dev/null +++ b/tauri-app/src-tauri/src/storage/project.rs @@ -0,0 +1,250 @@ +//! 项目数据存储 +//! +//! 负责项目元数据(meta.json)和分镜数据(segments.json)的读写。 +//! 所有操作通过 StorageEngine 的路径净化 + 原子写入 + 文件锁。 + +use std::fs; +use std::path::PathBuf; +use serde_json::Value; +use base64::Engine as _; +use crate::storage::engine::{ + atomic_write_json, atomic_write_bytes, with_file_lock, read_json, + sanitize_filename, StorageError, +}; +use crate::storage::paths::{ + get_project_dir, get_project_dir_path, get_project_assets_dir, get_project_videos_dir, get_project_images_dir, get_products_dir, +}; + +// ============================================================ +// 元数据读写 +// ============================================================ + +/// 保存项目元数据 +pub fn save_project_meta( + project_id: &str, + data: &Value, +) -> Result<(), StorageError> { + let dir = get_project_dir(project_id)?; + let path = dir.join("meta.json"); + with_file_lock(&path, || { + atomic_write_json(&path, data) + }) +} + +/// 保存项目元数据(原始 JSON 字符串) +pub fn save_project_meta_raw( + project_id: &str, + json_content: &str, +) -> Result<(), StorageError> { + let data: Value = serde_json::from_str(json_content)?; + save_project_meta(project_id, &data) +} + +/// 加载项目元数据 +pub fn load_project_meta( + project_id: &str, +) -> Result { + let dir = get_project_dir_path(project_id)?; + let path = dir.join("meta.json"); + match read_json(&path)? { + Some(data) => Ok(data), + None => Ok(Value::Null), + } +} + +// ============================================================ +// 分镜数据读写 +// ============================================================ + +/// 保存分镜数据 +pub fn save_project_segments( + project_id: &str, + segments: &Value, +) -> Result<(), StorageError> { + let dir = get_project_dir(project_id)?; + let path = dir.join("segments.json"); + with_file_lock(&path, || { + atomic_write_json(&path, segments) + }) +} + +/// 保存分镜数据(原始 JSON 字符串) +pub fn save_project_segments_raw( + project_id: &str, + json_content: &str, +) -> Result<(), StorageError> { + let data: Value = serde_json::from_str(json_content)?; + save_project_segments(project_id, &data) +} + +/// 加载分镜数据 +pub fn load_project_segments( + project_id: &str, +) -> Result { + let dir = get_project_dir_path(project_id)?; + let path = dir.join("segments.json"); + match read_json(&path)? { + Some(data) => Ok(data), + None => Ok(Value::Array(vec![])), + } +} + +// ============================================================ +// 项目列表 +// ============================================================ + +/// 列出所有本地项目 +/// +/// 遍历 projects 目录,读取每个项目的 meta.json。 +/// 读取失败的项目会被跳过并记录警告,不会导致整个列表失败。 +pub fn list_projects() -> Result, StorageError> { + let base = get_meijiaka_dir()?; + let projects_dir = base.join("projects"); + + if !projects_dir.exists() { + return Ok(vec![]); + } + + let mut projects = Vec::new(); + for entry in fs::read_dir(&projects_dir)? { + let entry = match entry { + Ok(e) => e, + Err(e) => { + eprintln!("[storage] Failed to read directory entry: {}", e); + continue; + } + }; + + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let meta_path = path.join("meta.json"); + match read_json::(&meta_path) { + Ok(Some(meta)) => projects.push(meta), + Ok(None) => { + eprintln!("[storage] meta.json not found for project: {:?}", path); + } + Err(e) => { + eprintln!("[storage] Failed to parse meta.json for {:?}: {}", path, e); + } + } + } + + Ok(projects) +} + +// ============================================================ +// 删除项目 +// ============================================================ + +/// 删除本地项目 +/// +/// 使用 `get_project_dir_path`(不自动创建目录), +/// 避免删除不存在项目时先创建空目录的 bug。 +pub fn delete_project(project_id: &str) -> Result<(), StorageError> { + let dir = get_project_dir_path(project_id)?; + if dir.exists() { + fs::remove_dir_all(&dir)?; + } + Ok(()) +} + +// ============================================================ +// 资源文件 +// ============================================================ + +/// 保存项目资源(Base64 解码后写入) +pub fn save_project_asset( + project_id: &str, + filename: &str, + base64_data: &str, +) -> Result { + let safe_filename = sanitize_filename(filename)?; + let dir = get_project_assets_dir(project_id)?; + let path = dir.join(&safe_filename); + + let decoded = base64::engine::general_purpose::STANDARD + .decode(base64_data) + .map_err(|e| StorageError::InvalidId(format!("base64 decode failed: {}", e)))?; + + atomic_write_bytes(&path, &decoded)?; + + Ok(path.to_string_lossy().to_string()) +} + +/// 获取视频保存路径(不写入文件) +/// 保存到项目 videos 目录下,与后端下载路径保持一致 +pub fn get_video_save_path( + project_id: &str, + filename: &str, +) -> Result { + let safe_filename = sanitize_filename(filename)?; + let dir = get_project_videos_dir(project_id)?; + Ok(dir.join(&safe_filename)) +} + +/// 获取图片保存路径(不写入文件) +/// 保存到项目 images 目录下 +pub fn get_image_save_path( + project_id: &str, + filename: &str, +) -> Result { + let safe_filename = sanitize_filename(filename)?; + let dir = get_project_images_dir(project_id)?; + Ok(dir.join(&safe_filename)) +} + +/// 获取封面保存路径 +pub fn get_cover_save_path( + project_id: &str, + filename: &str, +) -> Result { + let safe_filename = sanitize_filename(filename)?; + let dir = get_project_assets_dir(project_id)?; + Ok(dir.join(&safe_filename)) +} + +// ============================================================ +// 成品保存 +// ============================================================ + +/// 保存成品到 products 目录 +/// +/// 将源文件复制到 products 目录,并更新项目元数据中的 finalVideoPath。 +pub fn save_final_product_to_products( + project_id: &str, + source_path: &str, + product_filename: &str, +) -> Result { + let safe_filename = sanitize_filename(product_filename)?; + let products_dir = get_products_dir()?; + + // 目标路径 + let target_path = products_dir.join(&safe_filename); + fs::copy(source_path, &target_path)?; + + // 更新项目元数据 + let dir = get_project_dir(project_id)?; + let meta_path = dir.join("meta.json"); + + with_file_lock(&meta_path, || { + let mut meta = load_project_meta(project_id)?; + if meta.is_null() { + meta = serde_json::json!({}); + } + if let Some(obj) = meta.as_object_mut() { + obj.insert("finalVideoPath".to_string(), serde_json::json!(target_path.to_string_lossy().to_string())); + obj.insert("exportedAt".to_string(), serde_json::json!(chrono::Utc::now().timestamp_millis())); + } + atomic_write_json(&meta_path, &meta) + })?; + + Ok(target_path.to_string_lossy().to_string()) +} + +// 兼容旧接口:从 persistence.rs 迁移时需要的 get_meijiaka_dir +fn get_meijiaka_dir() -> Result { + crate::storage::paths::get_meijiaka_dir() +} diff --git a/tauri-app/src-tauri/src/utils.rs b/tauri-app/src-tauri/src/utils.rs new file mode 100644 index 0000000..4c09b0b --- /dev/null +++ b/tauri-app/src-tauri/src/utils.rs @@ -0,0 +1,21 @@ +//! 通用工具函数 + +use uuid::Uuid; +use tauri::Manager; + +/// 生成 UUID v4 字符串 +pub fn uuid_v4() -> String { + Uuid::new_v4().to_string() +} + +/// 获取项目存储目录 +/// 如果目录不存在则创建 +pub fn get_project_dir(app: &tauri::AppHandle) -> std::path::PathBuf { + let mut path = app.path().document_dir().unwrap_or_else(|_| std::env::temp_dir()); + path.push("Meijiaka"); + path.push("Projects"); + if !path.exists() { + let _ = std::fs::create_dir_all(&path); + } + path +} diff --git a/tauri-app/src-tauri/src/video_processing.rs b/tauri-app/src-tauri/src/video_processing.rs new file mode 100644 index 0000000..6c26ab5 --- /dev/null +++ b/tauri-app/src-tauri/src/video_processing.rs @@ -0,0 +1,176 @@ +//! 视频合成处理 + +use serde_json; +use crate::utils; +use crate::ApiResponse; + +/// 处理视频合成请求 +pub async fn handle_video_synthesis( + app: &tauri::AppHandle, + project_dir: &std::path::PathBuf, + payload: serde_json::Value, +) -> ApiResponse { + let payload_obj = match payload.as_object() { + Some(p) => p, + None => return ApiResponse { + code: 400, + message: "Invalid payload: expected object".to_string(), + data: None, + } + }; + + let video_paths = payload_obj.get("videoPaths").and_then(|v| v.as_array()); + let audio_path = payload_obj.get("audioPath").and_then(|v| v.as_str()); + let cover_path = payload_obj.get("coverPath").and_then(|v| v.as_str()); + let output_path = payload_obj.get("outputPath").and_then(|v| v.as_str()); + + let output_path = match output_path { + Some(p) => p, + None => return ApiResponse { + code: 400, + message: "Missing required field: outputPath".to_string(), + data: None, + } + }; + + if let Some(v_paths) = video_paths { + let mut concat_output = project_dir.clone(); + concat_output.push(format!("temp_concat_{}.mp4", utils::uuid_v4())); + let mut final_output = project_dir.clone(); + final_output.push(format!("final_out_{}.mp4", utils::uuid_v4())); + + let mut paths_vec: Vec = v_paths.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + if paths_vec.is_empty() { + return ApiResponse { + code: 400, + message: "No valid video paths provided".to_string(), + data: None + }; + } + + let mut temp_cover_video: Option = None; + + // 如果有封面,先转换为视频 + if let Some(c_path) = cover_path { + let mut cover_v_path = project_dir.clone(); + cover_v_path.push(format!("temp_cover_{}.mp4", utils::uuid_v4())); + let cover_v_path_str = cover_v_path.to_string_lossy().to_string(); + + match crate::ffmpeg_cmd::create_cover_video(app, c_path, &cover_v_path_str, "0.5").await { + Ok(_) => { + paths_vec.insert(0, cover_v_path_str); + temp_cover_video = Some(cover_v_path); + } + Err(e) => { + return ApiResponse { + code: 500, + message: format!("封面视频转换失败: {}", e), + data: None, + }; + } + } + } + + // 执行视频拼接 + match crate::ffmpeg_cmd::concat_videos_robust(app, paths_vec, concat_output.to_string_lossy().as_ref()).await { + Ok(_) => { + // 如果有音频,合并音频;否则直接使用拼接结果 + let has_audio = audio_path.map(|p| !p.is_empty()).unwrap_or(false); + + if has_audio { + // 合并音频 + match crate::ffmpeg_cmd::add_audio_to_video( + app, + concat_output.to_string_lossy().as_ref(), + audio_path.unwrap(), + final_output.to_string_lossy().as_ref(), + ).await { + Ok(_) => { + // 移动到目标路径 + let move_result = std::fs::rename(&final_output, output_path) + .or_else(|_| { + std::fs::copy(&final_output, output_path).map(|_| { + let _ = std::fs::remove_file(&final_output); + }) + }); + // 清理临时文件 + let _ = std::fs::remove_file(&concat_output); + if let Some(ref p) = temp_cover_video { + let _ = std::fs::remove_file(p); + } + match move_result { + Ok(_) => ApiResponse { + code: 200, + message: "视频合成成功".to_string(), + data: Some(serde_json::json!({ + "outputPath": output_path, + })), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("移动最终视频失败: {}", e), + data: None, + } + } + } + Err(e) => { + let _ = std::fs::remove_file(&concat_output); + if let Some(ref p) = temp_cover_video { + let _ = std::fs::remove_file(p); + } + ApiResponse { + code: 500, + message: format!("音频合并失败: {}", e), + data: None + } + } + } + } else { + // 无音频,移动到目标路径 + let move_result = std::fs::rename(&concat_output, output_path) + .or_else(|_| { + std::fs::copy(&concat_output, output_path).map(|_| { + let _ = std::fs::remove_file(&concat_output); + }) + }); + if let Some(ref p) = temp_cover_video { + let _ = std::fs::remove_file(p); + } + match move_result { + Ok(_) => ApiResponse { + code: 200, + message: "视频合成成功".to_string(), + data: Some(serde_json::json!({ + "outputPath": output_path, + })), + }, + Err(e) => ApiResponse { + code: 500, + message: format!("移动最终视频失败: {}", e), + data: None, + } + } + } + } + Err(e) => { + if let Some(ref p) = temp_cover_video { + let _ = std::fs::remove_file(p); + } + ApiResponse { + code: 500, + message: format!("视频拼接失败: {}", e), + data: None + } + } + } + } else { + ApiResponse { + code: 400, + message: "Missing required field: videoPaths".to_string(), + data: None + } + } +} diff --git a/tauri-app/src-tauri/tauri.conf.json b/tauri-app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..f4d2f15 --- /dev/null +++ b/tauri-app/src-tauri/tauri.conf.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "美家卡智影", + "version": "0.1.0", + "identifier": "cn.meijiaka.ai-video", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "美家卡 智影", + "width": 1440, + "height": 960, + "minWidth": 960, + "minHeight": 640, + "resizable": true, + "visible": true + } + ], + "security": { + "csp": null, + "capabilities": [ + "default" + ], + "assetProtocol": { + "enable": true, + "scope": [ + "$APPLOCALDATA/**", + "$APPDATA/**", + "$APPCONFIG/**", + "/**" + ] + } + } + }, + "plugins": { + "opener": {} + }, + "bundle": { + "active": true, + "targets": "all", + "externalBin": [ + "binaries/ffmpeg" + ], + "resources": { + "fonts/*": "fonts/" + }, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/tauri-app/src/App.css b/tauri-app/src/App.css new file mode 100644 index 0000000..241a169 --- /dev/null +++ b/tauri-app/src/App.css @@ -0,0 +1,21 @@ +.app-layout { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +.app-main { + flex: 1; + margin-left: var(--sidebar-width); + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.app-content { + flex: 1; + overflow: hidden auto; + padding: var(--spacing-xl); +} diff --git a/tauri-app/src/App.tsx b/tauri-app/src/App.tsx new file mode 100644 index 0000000..d1e4b82 --- /dev/null +++ b/tauri-app/src/App.tsx @@ -0,0 +1,163 @@ +/** + * App 入口 + * ======== + */ + +import { useState, createContext, useContext, useEffect } from 'react'; +import Sidebar from './components/Layout/Sidebar'; +import Login from './pages/Login/Login'; +import VideoCreation from './pages/VideoCreation'; +import MyWorks from './pages/ContentManagement/MyWorks'; +import AvatarClone from './pages/ContentManagement/AvatarClone'; +import AboutUs from './pages/Settings/AboutUs'; +import SystemUpdate from './pages/Settings/SystemUpdate'; +import ThemeSettings from './pages/Settings/ThemeSettings'; +import Profile from './pages/Profile/Profile'; +import UsageDetail from './pages/Profile/UsageDetail'; +import ToastContainer from './components/Toast/ToastContainer'; +import ProgressModal from './components/ProgressModal/ProgressModal'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import ConfirmModal from './components/Modal/ConfirmModal'; +import { useAuthStore, useSettingsStore } from './store'; +import { initProjectStore } from './store/projectStore'; + +import './App.css'; + +// ============================================================ +// 导航上下文 +// ============================================================ +interface NavigationContextType { + currentPage: string; + navigate: (page: string) => void; +} + +export const NavigationContext = createContext({ + currentPage: 'video-creation', + navigate: () => {}, +}); + +export const useNavigation = () => useContext(NavigationContext); + +// 页面类型 +export type PageType = + | 'video-creation' + | 'avatar-clone' + | 'my-works' + | 'about-us' + | 'system-update' + | 'theme-settings' + | 'profile' + | 'usage-detail'; + +// 页面组件映射表(模块级别,避免每次渲染重建组件实例) +const pages: Record = { + 'video-creation': VideoCreation, + 'avatar-clone': AvatarClone, + 'my-works': MyWorks, + 'about-us': AboutUs, + 'system-update': SystemUpdate, + 'theme-settings': ThemeSettings, + profile: Profile, + 'usage-detail': UsageDetail, +}; + +/** + * 渲染页面组件 + */ +function renderPage(page: PageType): React.ReactNode { + const Page = pages[page] || VideoCreation; + return ( + + + + ); +} + +/** + * 主应用组件 + */ +function App() { + const { isAuthenticated, logout, loadFromStorage } = useAuthStore(); + const { theme } = useSettingsStore(); + const [currentPage, setCurrentPage] = useState('video-creation'); + const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); + + // 应用启动时从 Tauri 文件加载认证状态 + useEffect(() => { + loadFromStorage().catch(console.error); + }, []); + + // 同步主题到 document.documentElement + useEffect(() => { + if (theme === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', theme); + } + }, [theme]); + + // 初始化项目存储(登录后加载本地项目) + useEffect(() => { + if (isAuthenticated) { + initProjectStore().catch(console.error); + } + }, [isAuthenticated]); + + + + const handleLogout = () => { + logout(); + setCurrentPage('video-creation'); + setShowLogoutConfirm(false); + }; + + const handleNavigate = (page: string) => { + if (page === 'logout') { + setShowLogoutConfirm(true); + return; + } + setCurrentPage(page as PageType); + }; + + return ( + <> + {/* 登录层 - 未登录时覆盖全屏 */} + {!isAuthenticated && } + + {/* 主应用层 - 始终渲染但可能被覆盖 */} +
+ + + +
+ +
+
{renderPage(currentPage)}
+
+
+ + {/* 退出登录确认弹窗 */} + + + + + + } + title="确定要退出登录吗?" + confirmText="确认退出" + cancelText="取消" + confirmButtonType="primary" + onConfirm={handleLogout} + onCancel={() => setShowLogoutConfirm(false)} + /> +
+
+ + ); +} + +export default App; diff --git a/tauri-app/src/__tests__/setup.ts b/tauri-app/src/__tests__/setup.ts new file mode 100644 index 0000000..db4f20b --- /dev/null +++ b/tauri-app/src/__tests__/setup.ts @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import { vi, afterEach } from 'vitest'; + +// 模拟完整的 localStorage +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +// 模拟 Tauri API +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +// 模拟 window.__TAURI_INTERNALS__ +Object.defineProperty(window, '__TAURI_INTERNALS__', { + value: {}, + writable: true, +}); + +// 清理 mocks 每个测试后 +afterEach(() => { + vi.clearAllMocks(); +}); diff --git a/tauri-app/src/api/adapters/projectAdapter.ts b/tauri-app/src/api/adapters/projectAdapter.ts new file mode 100644 index 0000000..aa2056a --- /dev/null +++ b/tauri-app/src/api/adapters/projectAdapter.ts @@ -0,0 +1,61 @@ +import type { ScriptShot } from '../types'; +import type { SaveSegmentsParams } from '../modules/project'; + +interface BackendSegment { + sequence?: number; + id?: number; + type?: 'segment' | 'empty_shot'; + scene?: string; + prompt?: string; + voiceover?: string; + duration?: string; + videoPath?: string; + videoUrl?: string; // 七牛云视频 URL + elementId?: number; + voiceId?: string; + alignmentResult?: unknown; + burnedVideoPath?: string; + burnedAt?: number; +} + +/** + * 将后端 Project Segment 转换为前端 ScriptShot + */ +export function adaptProjectSegment(seg: unknown): ScriptShot { + const raw = seg as BackendSegment; + return { + id: typeof raw.id === 'number' ? raw.id : (typeof raw.sequence === 'number' ? raw.sequence : 1), + type: raw.type === 'empty_shot' ? 'empty_shot' : 'segment', + scene: typeof raw.scene === 'string' ? raw.scene : undefined, + voiceover: typeof raw.voiceover === 'string' ? raw.voiceover : '', + duration: typeof raw.duration === 'string' ? raw.duration : '5s', + videoPath: raw.videoPath, + videoUrl: raw.videoUrl, // 七牛云视频 URL + elementId: raw.elementId, + voiceId: raw.voiceId, + alignmentResult: raw.alignmentResult as ScriptShot['alignmentResult'], + burnedVideoPath: raw.burnedVideoPath, + burnedAt: raw.burnedAt, + }; +} + +/** + * 将前端 ScriptShot 转换为后端保存格式 + */ +export function toBackendSegments(segments: ScriptShot[]): SaveSegmentsParams['segments'] { + return segments.map(seg => ({ + id: seg.id, + sequence: seg.id, + type: seg.type, + scene: seg.scene, + voiceover: seg.voiceover, + duration: seg.duration, + videoPath: seg.videoPath, + videoUrl: seg.videoUrl, // 七牛云视频 URL + elementId: seg.elementId, + voiceId: seg.voiceId, + alignmentResult: seg.alignmentResult, + burnedVideoPath: seg.burnedVideoPath, + burnedAt: seg.burnedAt, + })); +} diff --git a/tauri-app/src/api/adapters/scriptAdapter.ts b/tauri-app/src/api/adapters/scriptAdapter.ts new file mode 100644 index 0000000..3b8d5ef --- /dev/null +++ b/tauri-app/src/api/adapters/scriptAdapter.ts @@ -0,0 +1,38 @@ +import type { ScriptShot } from '../types'; + +/** + * 将后端返回的脚本生成结果标准化为前端 ScriptShot + */ +export function adaptScriptShots(data: unknown): ScriptShot[] { + if (!Array.isArray(data)) { + throw new Error('后端返回数据格式错误:期望数组'); + } + + return data.map((item: unknown, idx) => { + if (!item || typeof item !== 'object') { + return { + id: idx + 1, + type: 'segment' as const, + scene: undefined, + voiceover: '', + duration: '5s', + }; + } + + const raw = item as Record; + // 后端返回 duration 为 int(秒),统一转换为 "5s" 格式字符串 + let duration = '5s'; + if (typeof raw.duration === 'number') { + duration = `${Math.max(1, Math.round(raw.duration))}s`; + } else if (typeof raw.duration === 'string') { + duration = raw.duration; + } + return { + id: typeof raw.id === 'number' ? raw.id : idx + 1, + type: raw.type === 'empty_shot' ? 'empty_shot' : 'segment', + scene: typeof raw.scene === 'string' ? raw.scene : undefined, + voiceover: typeof raw.voiceover === 'string' ? raw.voiceover : '', + duration, + }; + }); +} diff --git a/tauri-app/src/api/client.ts b/tauri-app/src/api/client.ts new file mode 100644 index 0000000..6fa3553 --- /dev/null +++ b/tauri-app/src/api/client.ts @@ -0,0 +1,212 @@ +import { invoke } from '@tauri-apps/api/core'; +import { ApiResponse } from './types'; + +// 优先读取 Vite 环境变量,未配置时兜底为本地 Docker API 地址(与 docker-compose 端口一致) +export const PYTHON_API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080/api/v1'; + +// 简单的内存缓存 +type AuthData = { token?: string } | null; +let cachedAuth: AuthData = null; + +/** + * 检测是否在 Tauri 环境中运行 + */ +function isTauri(): boolean { + return typeof window !== 'undefined' && !!(window as unknown as Record).__TAURI__; +} + +/** + * 从存储加载 token(Tauri 文件存储优先,浏览器回退 localStorage) + */ +async function loadAuthToken(): Promise { + // 如果内存中有缓存,直接返回 + if (cachedAuth?.token) { + return cachedAuth.token; + } + + try { + if (isTauri()) { + const result = await invoke<{ code: number; data?: { token?: string } }>('load_auth_state'); + if (result.code === 200 && result.data?.token) { + cachedAuth = result.data; + return result.data.token; + } + } else { + // 浏览器环境:从 localStorage 读取 + const legacy = localStorage.getItem('ai-video-auth'); + if (legacy) { + try { + const parsed = JSON.parse(legacy); + const token = parsed?.state?.token; + if (token) { + cachedAuth = { token }; + return token; + } + } catch { + // 忽略解析错误 + } + } + } + } catch (e) { + console.error('[client] 加载认证 token 失败:', e); + } + + // 浏览器开发环境兜底:使用测试 token + if (!isTauri() && import.meta.env.DEV) { + const devToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMGVlYmM5OS05YzBiLTRlZjgtYmI2ZC02YmI5YmQzODBhMTEiLCJtb2JpbGUiOiIxMzgwMDEzODAwMCIsIm5pY2tuYW1lIjoiXHU2ZDRiXHU4YmQ1XHU3NTI4XHU2MjM3IiwiZXhwIjoxNzc2ODY3MDM0fQ._fl5HeX8YHaSJlWTb8s7lfHd2mLDzDZAP1NvvTQ_GpY'; + cachedAuth = { token: devToken }; + return devToken; + } + + return null; +} + +/** + * 清除 token 缓存(用于登出) + */ +export function clearAuthCache(): void { + cachedAuth = null; +} + +/** + * 转换 camelCase 对象key为 snake_case(发送给后端) + */ +function camelToSnake(obj: unknown): unknown { + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(item => camelToSnake(item)); + } + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); + result[snakeKey] = camelToSnake(value); + } + return result; +} + +/** + * 转换 snake_case 对象key为 camelCase(从后端接收) + */ +function snakeToCamel(obj: unknown): unknown { + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(item => snakeToCamel(item)); + } + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + result[camelKey] = snakeToCamel(value); + } + return result; +} + +/** + * HTTP 直连 Python 后端 + * 自动转换:前端 camelCase ↔ 后端 snake_case + */ +async function httpRequest( + method: string, + path: string, + body?: unknown, + options?: { headers?: Record; isFormData?: boolean; cache?: RequestCache } +): Promise { + const url = `${PYTHON_API_BASE_URL}${path}`; + + // 自动添加认证头(从 Tauri 文件存储读取) + const token = await loadAuthToken(); + const fetchOptions: RequestInit = { + method, + headers: { + ...(options?.isFormData ? {} : { 'Content-Type': 'application/json' }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options?.headers, + }, + cache: options?.cache, + }; + + if (body !== undefined && !options?.isFormData) { + // 自动转换 camelCase → snake_case 发给后端 + fetchOptions.body = JSON.stringify(camelToSnake(body)); + } else if (body !== undefined) { + fetchOptions.body = body as BodyInit; + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(extractErrorMessage(errorText, `HTTP ${response.status}: ${response.statusText}`)); + } + + const result = await response.json(); + return parseResponse(result); +} + +/** + * 解析后端响应并转换 camelCase + */ +function parseResponse(raw: string | unknown): T { + const result = typeof raw === 'string' ? JSON.parse(raw) : raw; + const convertedResult = snakeToCamel(result) as typeof result; + if (convertedResult && typeof convertedResult === 'object' && 'data' in convertedResult) { + return (convertedResult as ApiResponse).data as T; + } + return convertedResult as T; +} + +/** + * 从错误响应中提取可读消息 + */ +function extractErrorMessage(responseText: string, fallbackStatus: string): string { + let message = responseText || fallbackStatus; + try { + const errorData = JSON.parse(responseText); + if (typeof errorData.message === 'string') message = errorData.message; + else if (typeof errorData.detail === 'string') message = errorData.detail; + else if (typeof errorData.detail === 'object' && errorData.detail !== null) { + message = errorData.detail.message || errorData.detail.error || JSON.stringify(errorData.detail); + } + } catch {} + return message; +} + +/** + * HTTP API 客户端 + */ +export const client = { + async get(path: string): Promise { + const cacheBuster = `_t=${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const pathWithCacheBuster = path.includes('?') ? `${path}&${cacheBuster}` : `${path}?${cacheBuster}`; + return httpRequest('GET', pathWithCacheBuster, undefined, { + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate', + Pragma: 'no-cache', + }, + }); + }, + + async post(path: string, data?: unknown): Promise { + return httpRequest('POST', path, data); + }, + + async put(path: string, data?: unknown): Promise { + return httpRequest('PUT', path, data); + }, + + async patch(path: string, data?: unknown): Promise { + return httpRequest('PATCH', path, data); + }, + + async delete(path: string): Promise { + return httpRequest('DELETE', path); + }, + + async postForm(path: string, formData: FormData): Promise { + return httpRequest('POST', path, formData, { isFormData: true }); + }, +}; diff --git a/tauri-app/src/api/generated/openapi.json b/tauri-app/src/api/generated/openapi.json new file mode 100644 index 0000000..67e13fd --- /dev/null +++ b/tauri-app/src/api/generated/openapi.json @@ -0,0 +1,8498 @@ +/Users/0fun/work/ai-meijiaka/python-api/.venv/lib/python3.13/site-packages/pydantic/_internal/_fields.py:132: UserWarning: Field "model_id" in TestModelRequest has conflict with protected namespace "model_". + +You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ()`. + warnings.warn( +/Users/0fun/work/ai-meijiaka/python-api/.venv/lib/python3.13/site-packages/pydantic/_internal/_fields.py:132: UserWarning: Field "model_name" in ModelResponse has conflict with protected namespace "model_". + +You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ()`. + warnings.warn( +/Users/0fun/work/ai-meijiaka/python-api/.venv/lib/python3.13/site-packages/pydantic/_internal/_fields.py:132: UserWarning: Field "model_id" in GenerateRequest has conflict with protected namespace "model_". + +You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ()`. + warnings.warn( +/Users/0fun/work/ai-meijiaka/python-api/.venv/lib/python3.13/site-packages/pydantic/_internal/_fields.py:132: UserWarning: Field "model_id" in ScriptGenerateRequest has conflict with protected namespace "model_". + +You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ()`. + warnings.warn( +/Users/0fun/work/ai-meijiaka/python-api/.venv/lib/python3.13/site-packages/pydantic/_internal/_fields.py:132: UserWarning: Field "model_id" in PolishRequest has conflict with protected namespace "model_". + +You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ()`. + warnings.warn( +{ + "openapi": "3.1.0", + "info": { + "title": "美家卡智影 API", + "description": "美家卡智影 - AI 视频创作后端 API", + "version": "0.1.0" + }, + "paths": { + "/api/v1/auth/login": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Login", + "description": "手机号登录/注册\n\n- 如果手机号已存在,返回对应用户\n- 如果不存在,自动创建新用户\n- 返回 JWT Token 用于后续认证", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_LoginResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "Authentication" + ], + "summary": "Get Me", + "description": "获取当前登录用户信息", + "operationId": "get_me_api_v1_auth_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "Authentication" + ], + "summary": "Refresh Token", + "description": "刷新 JWT Token", + "operationId": "refresh_token_api_v1_auth_refresh_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/script/generate": { + "post": { + "tags": [ + "Script" + ], + "summary": "Generate Script", + "description": "同步生成脚本\n\n直接返回生成的分镜列表,适合快速预览。", + "operationId": "generate_script_api_v1_script_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateScriptRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_Segment__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/script/generate/stream": { + "post": { + "tags": [ + "Script" + ], + "summary": "Generate Script Stream", + "description": "流式生成脚本(SSE)\n\n返回 Server-Sent Events,包含进度更新和最终结果。\n前端通过 EventSource 接收实时进度。\n\n**SSE 事件类型:**\n- `start`: 开始生成\n- `analyzing`: 分析主题\n- `planning`: 规划结构\n- `generating`: AI 生成中\n- `parsing`: 解析结果\n- `complete`: 完成,包含 result 字段\n- `error`: 错误\n\n**示例事件流:**\n```\ndata: {\"type\": \"start\", \"progress\": 0, \"message\": \"开始生成脚本\"}\n\ndata: {\"type\": \"analyzing\", \"progress\": 15, \"message\": \"分析目标受众...\"}\n\ndata: {\"type\": \"complete\", \"progress\": 100, \"message\": \"成功生成 5 个分镜\", \"result\": [...]}\n```", + "operationId": "generate_script_stream_api_v1_script_generate_stream_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateScriptRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/script/polish": { + "post": { + "tags": [ + "Script" + ], + "summary": "Polish Content", + "description": "AI 润色文案/画面描述\n\n- `polishType=scene`: 润色画面描述(根据 shot_type 自动区分分镜/空镜)\n- `polishType=voiceover`: 润色配音文案\n\n参数:\n- `shot_type`: \"segment\"(分镜)或 \"empty_shot\"(空镜),画面润色时必填", + "operationId": "polish_content_api_v1_script_polish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__script__PolishRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_str_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/script/model-health": { + "get": { + "tags": [ + "Script" + ], + "summary": "Check Model Health", + "description": "检查 AI 模型健康状态\n\n返回所有配置的模型及其可用性状态。", + "operationId": "check_model_health_api_v1_script_model_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ModelHealthResponse_" + } + } + } + } + } + } + }, + "/api/v1/script/test-model": { + "post": { + "tags": [ + "Script" + ], + "summary": "Test Model", + "description": "测试指定模型连接\n\n发送一个简单的测试请求,验证模型是否可用。", + "operationId": "test_model_api_v1_script_test_model_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestModelRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_TestModelResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/platforms": { + "get": { + "tags": [ + "AI Models" + ], + "summary": "List Platforms", + "description": "获取所有平台列表", + "operationId": "list_platforms_api_v1_ai_platforms_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_PlatformResponse__" + } + } + } + } + } + } + }, + "/api/v1/ai/models": { + "get": { + "tags": [ + "AI Models" + ], + "summary": "List Models", + "description": "获取模型列表\n\nArgs:\n capability: 按能力过滤,如 script、polish、chat", + "operationId": "list_models_api_v1_ai_models_get", + "parameters": [ + { + "name": "capability", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Capability" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_ModelResponse__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/generate": { + "post": { + "tags": [ + "AI Models" + ], + "summary": "Generate Text", + "description": "文本生成(自动路由到对应平台)", + "operationId": "generate_text_api_v1_ai_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_GenerateResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/health": { + "get": { + "tags": [ + "AI Models" + ], + "summary": "Health Check", + "description": "检查模型健康状态", + "operationId": "health_check_api_v1_ai_health_get", + "parameters": [ + { + "name": "model_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_HealthResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/platforms/{platform_id}/test": { + "get": { + "tags": [ + "AI Models" + ], + "summary": "Test Platform Connection", + "description": "测试平台连接", + "operationId": "test_platform_connection_api_v1_ai_platforms__platform_id__test_get", + "parameters": [ + { + "name": "platform_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Platform Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/reload": { + "post": { + "tags": [ + "AI Models" + ], + "summary": "Reload Config", + "description": "重新加载配置文件", + "operationId": "reload_config_api_v1_ai_reload_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + } + } + } + }, + "/api/v1/ai/prompts/templates": { + "get": { + "tags": [ + "AI Models" + ], + "summary": "Get Prompt Templates", + "description": "获取所有可用的 Prompt 模板配置\n\n包括脚本类型、视频风格、语气风格等选项。", + "operationId": "get_prompt_templates_api_v1_ai_prompts_templates_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PromptTemplatesResponse_" + } + } + } + } + } + } + }, + "/api/v1/ai/prompts/build": { + "post": { + "tags": [ + "AI Models" + ], + "summary": "Build System Prompt", + "description": "构建系统 Prompt(用于调试和预览)\n\n返回构建好的系统 Prompt,可用于前端预览或调试。", + "operationId": "build_system_prompt_api_v1_ai_prompts_build_post", + "parameters": [ + { + "name": "duration", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "title": "Duration" + } + }, + { + "name": "script_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "干货型", + "title": "Script Type" + } + }, + { + "name": "video_style", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "口播", + "title": "Video Style" + } + }, + { + "name": "tone", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tone" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/scripts/generate": { + "post": { + "tags": [ + "AI Models" + ], + "summary": "Generate Script", + "description": "生成家装行业短视频脚本\n\n使用专业的 Prompt 模板生成包含分镜+空镜的混合脚本。\n针对前端展示优化,返回分镜数、空镜数、总字数等统计信息。", + "operationId": "generate_script_api_v1_ai_scripts_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScriptGenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ScriptGenerateResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/ai/scripts/polish": { + "post": { + "tags": [ + "AI Models" + ], + "summary": "Polish Script Content", + "description": "润色脚本内容\n\n对场景描述或口播文案进行专业润色。", + "operationId": "polish_script_content_api_v1_ai_scripts_polish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__api__v1__ai_models__PolishRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PolishResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/omni": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Omni Video", + "description": "Omni-Video 多模态视频生成\n\n支持文本、图片、主体、视频等多种输入方式组合生成视频。\n适用于 kling-v3-omni 和 kling-video-o1 模型。\n\n**特性:**\n- 支持 3-15 秒视频生成\n- 支持多镜头和智能分镜 (shotType=intelligence)\n- 支持引用主体、图片、视频作为参考\n- 支持 <<>>/<<>>/<<>> 语法引用提示词中的资源", + "operationId": "create_omni_video_api_v1_klingai_videos_omni_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OmniVideoRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "List Omni Video Tasks", + "description": "查询 Omni-Video 任务列表\n\n查询历史 Omni-Video 任务列表。", + "operationId": "list_omni_video_tasks_api_v1_klingai_videos_omni_get", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/omni/{task_id}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Omni Video Task", + "description": "查询 Omni-Video 任务状态\n\n查询指定 Omni-Video 任务的执行状态和结果。", + "operationId": "get_omni_video_task_api_v1_klingai_videos_omni__task_id__get", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_TaskStatusResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/image2video": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Image To Video", + "description": "图生视频\n\n根据输入图片生成视频。", + "operationId": "create_image_to_video_api_v1_klingai_videos_image2video_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Image2VideoRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/extend": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Extend Video", + "description": "视频延长\n\n延长现有视频的时长。", + "operationId": "extend_video_api_v1_klingai_videos_extend_post", + "parameters": [ + { + "name": "video_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Video Id" + } + }, + { + "name": "prompt", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt" + } + }, + { + "name": "duration", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 5, + "title": "Duration" + } + }, + { + "name": "callback_url", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/identify-face": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Identify Face", + "description": "对口型前置:人脸识别\n\n分析视频中的人脸信息,返回 sessionId 和 faceId,用于后续的 advanced-lip-sync。", + "operationId": "identify_face_api_v1_klingai_videos_identify_face_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdentifyFaceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_IdentifyFaceResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/advanced-lip-sync": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Advanced Lip Sync", + "description": "新版对口型视频生成\n\n基于 KlingAI advanced-lip-sync 接口,先调用 /videos/identify-face 获取 sessionId 和 faceId,\n再传入本接口生成对口型视频。\n\n支持 audio_id(TTS 生成)或 soundFile(外部音频 URL)驱动口型。", + "operationId": "create_advanced_lip_sync_api_v1_klingai_videos_advanced_lip_sync_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdvancedLipSyncRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/videos/advanced-lip-sync/{taskId}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Advanced Lip Sync Task", + "description": "查询新版对口型任务状态", + "operationId": "get_advanced_lip_sync_task_api_v1_klingai_videos_advanced_lip_sync__taskId__get", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Taskid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_TaskStatusResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/images/omni": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Omni Image", + "description": "Omni-Image 图像生成\n\n支持文本、主体、参考图等多种输入方式组合生成图像。", + "operationId": "create_omni_image_api_v1_klingai_images_omni_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OmniImageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/images/omni/{task_id}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Omni Image Task", + "description": "查询 Omni-Image 任务状态", + "operationId": "get_omni_image_task_api_v1_klingai_images_omni__task_id__get", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_TaskStatusResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/images/generations": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Image", + "description": "文生图\n\n根据文本描述生成图像。", + "operationId": "create_image_api_v1_klingai_images_generations_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageGenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/virtual-tryon": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Virtual Tryon", + "description": "虚拟试穿\n\n将衣服虚拟试穿到人物身上。", + "operationId": "create_virtual_tryon_api_v1_klingai_virtual_tryon_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VirtualTryonRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/tasks/{taskId}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Taskstatus", + "description": "查询任务状态\n\n查询指定任务的执行状态和结果。\n\nArgs:\n taskId: 任务 ID\n task_type: 任务类型 (video, image2video, image, lip-sync, virtual-tryon)", + "operationId": "get_taskStatus_api_v1_klingai_tasks__taskId__get", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Taskid" + } + }, + { + "name": "task_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "video", + "title": "Task Type" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_TaskStatusResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/tasks": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "List Tasks", + "description": "查询任务列表\n\n查询历史任务列表。", + "operationId": "list_tasks_api_v1_klingai_tasks_get", + "parameters": [ + { + "name": "task_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "video", + "title": "Task Type" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 10, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/elements": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "List Elements", + "description": "查询主体列表\n\n获取所有已创建的主体(自定义元素)列表。", + "operationId": "list_elements_api_v1_klingai_elements_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_ElementResponse__" + } + } + } + } + } + }, + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Element", + "description": "创建主体(自定义元素)\n\n通过上传图片或视频创建可复用的主体,用于视频/图像生成时保持角色一致性。\n\n图片要求:\n- 格式:jpg, jpeg, png\n- 大小:≤10MB\n- 数量:正面图 + 1-3张其他角度\n\n视频要求:\n- 格式:mp4, mov\n- 时长:3-8秒\n- 分辨率:1080P\n- 大小:≤200MB", + "operationId": "create_element_api_v1_klingai_elements_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateElementRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ElementResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/elements/{elementId}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Element", + "description": "查询单个主体详情\n\n获取指定主体的详细信息。", + "operationId": "get_element_api_v1_klingai_elements__elementId__get", + "parameters": [ + { + "name": "elementId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Elementid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ElementResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Delete Element", + "description": "删除主体\n\n删除不再使用的主体(自定义元素)。", + "operationId": "delete_element_api_v1_klingai_elements__elementId__delete", + "parameters": [ + { + "name": "elementId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Elementid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/elements/ai-multi-shot": { + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Ai Multishot", + "description": "智能补全主体不同角度图片\n\n通过主体正面图,自动推理出该主体其他角度图片。\n每次可生成3组结果供选择,每次扣减0.5积分。\n\n使用流程:\n1. 调用此接口传入正面图\n2. 轮询查询任务状态\n3. 获取生成的多组角度图片\n4. 选择合适的图片创建主体", + "operationId": "ai_multiShot_api_v1_klingai_elements_ai_multi_shot_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AiMultiShotRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AiMultiShotResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/elements/ai-multi-shot/{taskId}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Ai Multishot Task", + "description": "查询智能补全主体图任务状态\n\n获取指定任务的执行状态和生成的多角度图片结果。", + "operationId": "get_ai_multiShot_task_api_v1_klingai_elements_ai_multi_shot__taskId__get", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Taskid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/voices/custom": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "List Custom Voices", + "description": "查询自定义音色列表\n\n获取所有已创建的自定义音色。", + "operationId": "list_custom_voices_api_v1_klingai_voices_custom_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_VoiceInfo__" + } + } + } + } + } + }, + "post": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Create Custom Voice", + "description": "创建自定义音色\n\n通过上传音频文件或引用历史视频创建自定义音色,用于对口型视频。\n\n音频要求:\n- 格式:mp3, wav, mp4, mov\n- 时长:5-30 秒\n- 人声干净、无杂音、单一人声", + "operationId": "create_custom_voice_api_v1_klingai_voices_custom_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomVoiceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CreateCustomVoiceResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/voices/custom/{voiceId}": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Get Custom Voice", + "description": "查询单个自定义音色\n\n获取指定自定义音色的详细信息。", + "operationId": "get_custom_voice_api_v1_klingai_voices_custom__voiceId__get", + "parameters": [ + { + "name": "voiceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Voiceid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "Delete Custom Voice", + "description": "删除自定义音色\n\n删除不再使用的自定义音色。", + "operationId": "delete_custom_voice_api_v1_klingai_voices_custom__voiceId__delete", + "parameters": [ + { + "name": "voiceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Voiceid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/klingai/voices/presets": { + "get": { + "tags": [ + "KlingAI", + "KlingAI" + ], + "summary": "List Preset Voices", + "description": "查询官方预设音色列表\n\n获取 KlingAI 提供的官方音色列表。", + "operationId": "list_preset_voices_api_v1_klingai_voices_presets_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_VoiceInfo__" + } + } + } + } + } + } + }, + "/api/v1/qiniu/upload-token": { + "post": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Get Upload Token", + "description": "获取上传凭证(客户端直传)\n\n前端获取 Token 后,可直接上传到七牛云,无需经过服务端。\n\n上传地址: https://upload.qiniup.com\n请求方式: POST (multipart/form-data)\n请求参数:\n - token: 上传凭证(本接口返回)\n - key: 文件存储 Key(本接口返回)\n - file: 文件内容", + "operationId": "get_upload_token_api_v1_qiniu_upload_token_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UploadTokenResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/qiniu/upload/audio": { + "post": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Upload Audio", + "description": "上传音频文件\n\n支持格式: MP3, WAV, M4A, AAC, OGG\n文件会自动存储到: audios/{userId}/{date}/{uuid}.{ext}", + "operationId": "upload_audio_api_v1_qiniu_upload_audio_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_audio_api_v1_qiniu_upload_audio_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_FileUploadResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/qiniu/upload/video": { + "post": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Upload Video", + "description": "上传视频文件\n\n支持格式: MP4, MOV, AVI, WebM\n文件会自动存储到: videos/{userId}/{date}/{uuid}.{ext}", + "operationId": "upload_video_api_v1_qiniu_upload_video_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_video_api_v1_qiniu_upload_video_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_FileUploadResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/qiniu/upload/avatar": { + "post": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Upload Avatar", + "description": "上传形象克隆视频\n\n用于形象克隆功能,上传的视频将同时用于创建自定义音色和主体。\n\nKlingAI 要求:\n - 格式: MP4, MOV\n - 时长: 5-30 秒 (建议 5-8 秒)\n - 大小: 不超过 200MB\n - 分辨率: 高度 720px~2160px\n - 内容: 写实风格人物正面特写,人脸清晰、无遮挡,视频中有清晰人声\n\n文件存储路径: meijiaka/avatars/{userId}/{date}/{uuid}.{ext}\n\n重复检测:\n - 如果提供了 fileHash,会检查是否已有相同文件的任务在进行中\n - 返回的 isDuplicate 表示是否复用了已有资源\n - existingTaskId 表示已存在任务的ID(如果有)", + "operationId": "upload_avatar_api_v1_qiniu_upload_avatar_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_avatar_api_v1_qiniu_upload_avatar_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_FileUploadResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/qiniu/files/{key}": { + "get": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Get File Info", + "description": "获取文件信息\n\nArgs:\n key: 文件存储 Key(路径格式)", + "operationId": "get_file_info_api_v1_qiniu_files__key__get", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Delete File", + "description": "删除文件\n\nArgs:\n key: 文件存储 Key", + "operationId": "delete_file_api_v1_qiniu_files__key__delete", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Key" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/qiniu/refresh-cdn": { + "post": { + "tags": [ + "Qiniu Storage", + "Qiniu Storage" + ], + "summary": "Refresh Cdn", + "description": "刷新 CDN 缓存\n\n文件更新后,调用此接口刷新 CDN 缓存,确保用户访问到最新内容。", + "operationId": "refresh_cdn_api_v1_qiniu_refresh_cdn_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Keys" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/generate": { + "post": { + "tags": [ + "Video", + "Video" + ], + "summary": "Create Video Generation", + "description": "创建视频生成任务\n\n接收项目ID、数字人ID和分镜列表,创建视频生成作业。\n支持 SSE 流式查询进度。\n\n**分镜类型说明:**\n- `segment`: 分镜(带数字人),使用 omni-video 接口,需要 human_id\n- `empty_shot`: 空镜,使用文生图 + 图生视频流程\n\n**调用流程:**\n1. 调用此接口创建任务,获取 job_id\n2. 使用 SSE 接口 `/video/jobs/{job_id}/stream` 监听进度\n3. 或使用 `/video/jobs/{job_id}` 查询状态", + "operationId": "create_video_generation_api_v1_video_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VideoGenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__schemas__common__ApiResponse_VideoGenerateResponse___2" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/jobs/{job_id}": { + "get": { + "tags": [ + "Video", + "Video" + ], + "summary": "Get Video Job", + "description": "查询视频生成作业详情\n\n获取指定作业的详细信息和所有分镜的处理结果。", + "operationId": "get_video_job_api_v1_video_jobs__job_id__get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_VideoJobDetail_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/jobs/{job_id}/stream": { + "get": { + "tags": [ + "Video", + "Video" + ], + "summary": "Stream Video Job", + "description": "SSE 流式获取视频生成进度\n\n使用 Server-Sent Events 实时推送视频生成进度。\n\n**事件类型:**\n- `start`: 开始生成\n- `processing`: 处理中(包含进度信息)\n- `finalizing`: 完成整理\n- `complete`: 全部完成\n- `error`: 发生错误\n\n**示例:**\n```\nconst eventSource = new EventSource('/api/v1/video/jobs/{job_id}/stream');\neventSource.onmessage = (e) => {\n const data = JSON.parse(e.data);\n console.log(data.progress + '%: ' + data.message);\n};\n```", + "operationId": "stream_video_job_api_v1_video_jobs__job_id__stream_get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/jobs/{job_id}/status": { + "get": { + "tags": [ + "Video", + "Video" + ], + "summary": "Get Video Job Status", + "description": "获取视频生成作业状态(简化版)", + "operationId": "get_video_job_status_api_v1_video_jobs__job_id__status_get", + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_VideoJobStatus_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/library": { + "get": { + "tags": [ + "Video", + "Video" + ], + "summary": "Get Digital Humans", + "description": "获取数字人素材库\n\n返回系统预设的数字人列表。", + "operationId": "get_digital_humans_api_v1_video_library_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_DigitalHuman__" + } + } + } + } + } + } + }, + "/api/v1/video/upload": { + "post": { + "tags": [ + "Video", + "Video" + ], + "summary": "Upload Video", + "description": "上传人物视频作为数字人素材\n\n文件要求:\n- 格式:mp4, mov\n- 时长:2-60秒\n- 分辨率:720p 或 1080p", + "operationId": "upload_video_api_v1_video_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_video_api_v1_video_upload_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DigitalHuman_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/{video_id}/download": { + "get": { + "tags": [ + "Video", + "Video" + ], + "summary": "Download Video", + "description": "下载视频文件\n\n支持三种查找位置:\n1. data/video/{video_id}.mp4 - 传统存储\n2. data/uploads/{video_id}.ext - 上传文件\n3. ~/Documents/Meijiaka/projects/*/videos/{video_id}.mp4 - 项目生成的视频\n 文件名格式: scene_{shot_id}.mp4", + "operationId": "download_video_api_v1_video__video_id__download_get", + "parameters": [ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Video Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/video/{video_id}/thumbnail": { + "get": { + "tags": [ + "Video", + "Video" + ], + "summary": "Get Video Thumbnail", + "description": "获取视频缩略图", + "operationId": "get_video_thumbnail_api_v1_video__video_id__thumbnail_get", + "parameters": [ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Video Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/avatar/clone": { + "post": { + "tags": [ + "Avatar" + ], + "summary": "Clone Avatar", + "description": "提交形象克隆任务\n\n立即返回 task_id,前端通过 SSE 或轮询跟踪进度。\n实际串行流程由 Async Engine Scheduler 异步执行。", + "operationId": "clone_avatar_api_v1_avatar_clone_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloneAvatarRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CloneAvatarResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/avatar/tasks/{task_id}": { + "get": { + "tags": [ + "Avatar" + ], + "summary": "Get Avatar Task Status", + "description": "查询形象克隆任务状态", + "operationId": "get_avatar_task_status_api_v1_avatar_tasks__task_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AvatarTaskStatusResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/avatar/clone/stream": { + "get": { + "tags": [ + "Avatar" + ], + "summary": "Sse Avatar Clone", + "description": "SSE 流:实时推送形象克隆任务状态\n\n前端连接后,每 3 秒推送一次状态,直到任务结束(succeed / failed / timeout)。", + "operationId": "sse_avatar_clone_api_v1_avatar_clone_stream_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "任务 ID", + "title": "Task Id" + }, + "description": "任务 ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/avatar/tasks/{task_id}/retry": { + "post": { + "tags": [ + "Avatar" + ], + "summary": "Retry Avatar Task", + "description": "重试失败或超时的形象克隆任务\n\n仅允许对 voice_failed / element_failed / timeout 状态的任务重试。\n重试时会重置状态为 pending 并重新注册到 Async Engine。", + "operationId": "retry_avatar_task_api_v1_avatar_tasks__task_id__retry_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/avatar/{avatar_id}": { + "delete": { + "tags": [ + "Avatar" + ], + "summary": "Delete Avatar", + "description": "删除形象:软删除 DB 记录 + 异步清理 Kling 资源", + "operationId": "delete_avatar_api_v1_avatar__avatar_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "avatar_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Avatar Id" + } + }, + { + "name": "voice_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Voice Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "Avatar" + ], + "summary": "Update Avatar Name", + "description": "更新形象名称\n\n同步更新本地和后端 DB,保证数据一致。", + "operationId": "update_avatar_name_api_v1_avatar__avatar_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "avatar_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Avatar Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAvatarNameRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/avatar/library": { + "get": { + "tags": [ + "Avatar" + ], + "summary": "Get Avatar Library", + "description": "获取当前用户的克隆形象库\n\n返回 DB 中状态为 succeed 且未软删除的记录,供前端与 localStorage 合并。", + "operationId": "get_avatar_library_api_v1_avatar_library_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_AvatarItem__" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/avatar/health": { + "get": { + "tags": [ + "Avatar" + ], + "summary": "Get Avatar Health", + "description": "获取形象克隆服务健康状态\n\n普通用户只能看到自己的任务统计,管理员可以看到全部。", + "operationId": "get_avatar_health_api_v1_avatar_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AvatarHealthResponse_" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/avatar/admin/trigger-recovery": { + "post": { + "tags": [ + "Avatar" + ], + "summary": "Admin Trigger Recovery", + "description": "手动触发卡住任务恢复(管理员接口)\n\nAsync Engine 会自动轮询,无需手动触发恢复。\n此接口保留用于向后兼容和手动排查。", + "operationId": "admin_trigger_recovery_api_v1_avatar_admin_trigger_recovery_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/system/health": { + "get": { + "tags": [ + "System" + ], + "summary": "System Health", + "description": "系统健康检查(详细版)", + "operationId": "system_health_api_v1_system_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + } + } + } + }, + "/api/v1/system/version": { + "get": { + "tags": [ + "System" + ], + "summary": "System Version", + "description": "获取系统版本信息", + "operationId": "system_version_api_v1_system_version_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + } + } + } + }, + "/api/v1/caption/submit": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Submit Caption Task", + "description": "提交字幕生成任务\n\n提交音频/视频文件URL,生成带时间轴的字幕。", + "operationId": "submit_caption_task_api_v1_caption_submit_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionSubmitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CaptionTaskResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/query/{task_id}": { + "get": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Query Caption Task", + "description": "查询字幕任务结果\n\nArgs:\n task_id: 任务ID\n blocking: 是否阻塞等待结果 (默认True)", + "operationId": "query_caption_task_api_v1_caption_query__task_id__get", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "blocking", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Blocking" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CaptionResult_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/generate": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Generate Caption", + "description": "生成字幕(完整流程)\n\n提交任务并轮询结果,直接返回最终字幕数据。\n适用于不需要异步处理的场景。", + "operationId": "generate_caption_api_v1_caption_generate_post", + "parameters": [ + { + "name": "max_wait_time", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 120, + "title": "Max Wait Time" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionSubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CaptionResult_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/generate-ass": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Generate Ass", + "description": "生成 ASS 格式字幕(完整流程,使用抖音美好体)\n\nArgs:\n video_width: 视频宽度(默认 1080)\n video_height: 视频高度(默认 1920)", + "operationId": "generate_ass_api_v1_caption_generate_ass_post", + "parameters": [ + { + "name": "video_width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1080, + "title": "Video Width" + } + }, + { + "name": "video_height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1920, + "title": "Video Height" + } + }, + { + "name": "max_wait_time", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 120, + "title": "Max Wait Time" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionSubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/generate-srt": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Generate Srt", + "description": "生成 SRT 格式字幕(完整流程)\n\n直接返回 SRT 格式字幕文件内容。", + "operationId": "generate_srt_api_v1_caption_generate_srt_post", + "parameters": [ + { + "name": "max_wait_time", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 120, + "title": "Max Wait Time" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionSubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_SrtSubtitleResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/ata/submit": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Submit Auto Align Task", + "description": "提交自动字幕打轴任务\n\n为已有字幕文本自动配上时间轴。", + "operationId": "submit_auto_align_task_api_v1_caption_ata_submit_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoAlignSubmitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_CaptionTaskResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/ata/query/{task_id}": { + "get": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Query Auto Align Task", + "description": "查询打轴任务结果", + "operationId": "query_auto_align_task_api_v1_caption_ata_query__task_id__get", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + }, + { + "name": "blocking", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": true, + "title": "Blocking" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AutoAlignResult_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/ata/align": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Auto Align Caption", + "description": "自动字幕打轴(完整流程)\n\n提交打轴任务并轮询结果,直接返回最终数据。", + "operationId": "auto_align_caption_api_v1_caption_ata_align_post", + "parameters": [ + { + "name": "max_wait_time", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 120, + "title": "Max Wait Time" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoAlignSubmitRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/convert/ass": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Convert To Ass", + "description": "将字幕结果转换为 ASS 格式(使用抖音美好体)", + "operationId": "convert_to_ass_api_v1_caption_convert_ass_post", + "parameters": [ + { + "name": "video_width", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1080, + "title": "Video Width" + } + }, + { + "name": "video_height", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1920, + "title": "Video Height" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionResult-Input" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/convert/srt": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Convert To Srt", + "description": "将字幕结果转换为 SRT 格式\n\n用于将 /generate 返回的原始数据转换为 SRT 格式。", + "operationId": "convert_to_srt_api_v1_caption_convert_srt_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionResult-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/caption/convert/vtt": { + "post": { + "tags": [ + "Caption", + "Caption" + ], + "summary": "Convert To Vtt", + "description": "将字幕结果转换为 WebVTT 格式", + "operationId": "convert_to_vtt_api_v1_caption_convert_vtt_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptionResult-Input" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/{task_type}": { + "post": { + "tags": [ + "Tasks", + "Tasks" + ], + "summary": "Create Task", + "description": "创建新任务\n\n根据任务类型写入 Redis,由 Async Engine Scheduler 统一调度。", + "operationId": "create_task_api_v1_tasks__task_type__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_type", + "in": "path", + "required": true, + "schema": { + "enum": [ + "video", + "image", + "script", + "subtitle", + "copy", + "avatar_clone" + ], + "type": "string", + "title": "Task Type" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskCreateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/{task_id}": { + "get": { + "tags": [ + "Tasks", + "Tasks" + ], + "summary": "Get Task Status", + "description": "查询任务状态\n\n前端通过轮询此接口获取任务进度", + "operationId": "get_task_status_api_v1_tasks__task_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__api__v1__tasks__TaskStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/{task_id}/result": { + "get": { + "tags": [ + "Tasks", + "Tasks" + ], + "summary": "Get Task Result", + "description": "获取任务结果(简化接口,直接返回 result 字段)", + "operationId": "get_task_result_api_v1_tasks__task_id__result_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Response Get Task Result Api V1 Tasks Task Id Result Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health Check", + "description": "服务健康检查", + "operationId": "health_check_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/": { + "get": { + "tags": [ + "System" + ], + "summary": "Root", + "description": "API 根路径", + "operationId": "root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AdvancedLipSyncRequest": { + "properties": { + "session_id": { + "type": "string", + "title": "Session Id", + "description": "人脸识别返回的会话ID" + }, + "face_choose": { + "items": { + "$ref": "#/components/schemas/FaceChooseItem" + }, + "type": "array", + "title": "Face Choose", + "description": "人脸对口型配置列表" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "session_id", + "face_choose" + ], + "title": "AdvancedLipSyncRequest", + "description": "新版对口型请求(advanced-lip-sync)" + }, + "AiMultiShotRequest": { + "properties": { + "frontal_image": { + "type": "string", + "title": "Frontal Image", + "description": "主体正面参考图 URL", + "example": "https://example.com/front.jpg" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "frontal_image" + ], + "title": "AiMultiShotRequest", + "description": "智能补全主体图请求" + }, + "AiMultiShotResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "task_status": { + "type": "string", + "title": "Task Status" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "task_id", + "task_status", + "created_at", + "updated_at" + ], + "title": "AiMultiShotResponse", + "description": "智能补全主体图响应" + }, + "ApiResponse_AiMultiShotResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AiMultiShotResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[AiMultiShotResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_AutoAlignResult_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AutoAlignResult" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[AutoAlignResult]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_AvatarHealthResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AvatarHealthResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[AvatarHealthResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_AvatarTaskStatusResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AvatarTaskStatusResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[AvatarTaskStatusResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_CaptionResult_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/CaptionResult-Output" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[CaptionResult]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_CaptionTaskResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/CaptionTaskResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[CaptionTaskResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_CloneAvatarResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/CloneAvatarResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[CloneAvatarResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_CreateCustomVoiceResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/CreateCustomVoiceResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[CreateCustomVoiceResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_DigitalHuman_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/DigitalHuman" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[DigitalHuman]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_ElementResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ElementResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[ElementResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_FileUploadResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/FileUploadResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[FileUploadResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_GenerateResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/GenerateResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[GenerateResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_HealthResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/HealthResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[HealthResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_IdentifyFaceResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/IdentifyFaceResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[IdentifyFaceResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_LoginResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoginResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[LoginResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_ModelHealthResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelHealthResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[ModelHealthResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_PolishResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/PolishResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[PolishResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_PromptTemplatesResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/PromptTemplatesResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[PromptTemplatesResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_ScriptGenerateResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScriptGenerateResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[ScriptGenerateResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_SrtSubtitleResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/SrtSubtitleResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[SrtSubtitleResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_TaskStatusResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/app__api__v1__klingai__TaskStatusResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[TaskStatusResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_TestModelResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/TestModelResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[TestModelResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_UploadTokenResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/UploadTokenResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[UploadTokenResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_VideoJobDetail_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/VideoJobDetail" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[VideoJobDetail]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_VideoJobStatus_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/VideoJobStatus" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[VideoJobStatus]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_dict_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[dict]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_AvatarItem__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AvatarItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[AvatarItem]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_DigitalHuman__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/DigitalHuman" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[DigitalHuman]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_ElementResponse__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ElementResponse" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[ElementResponse]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_ModelResponse__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ModelResponse" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[ModelResponse]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_PlatformResponse__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/PlatformResponse" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[PlatformResponse]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_Segment__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Segment" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[Segment]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_list_VoiceInfo__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/VoiceInfo" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[list[VoiceInfo]]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "ApiResponse_str_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Data", + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[str]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "AutoAlignResult": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码: 0=成功, 2000=处理中" + }, + "message": { + "type": "string", + "title": "Message", + "description": "状态信息" + }, + "duration": { + "type": "number", + "title": "Duration", + "description": "音频时长(秒)" + }, + "utterances": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CaptionUtterance" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Utterances", + "description": "打轴后的字幕时间轴" + } + }, + "type": "object", + "required": [ + "code", + "message", + "duration" + ], + "title": "AutoAlignResult", + "description": "自动字幕打轴结果" + }, + "AutoAlignSubmitRequest": { + "properties": { + "audio_url": { + "type": "string", + "title": "Audio Url", + "description": "音频/视频文件URL" + }, + "audio_text": { + "type": "string", + "title": "Audio Text", + "description": "要打轴的字幕文本" + }, + "caption_type": { + "type": "string", + "title": "Caption Type", + "description": "识别类型: speech(说话), singing(歌词)", + "default": "speech" + }, + "sta_punc_mode": { + "type": "integer", + "maximum": 3.0, + "minimum": 1.0, + "title": "Sta Punc Mode", + "description": "标点模式: 1=省略句末, 2=空格代替, 3=保留完整", + "default": 3 + } + }, + "type": "object", + "required": [ + "audio_url", + "audio_text" + ], + "title": "AutoAlignSubmitRequest", + "description": "自动字幕打轴任务提交请求" + }, + "AvatarHealthResponse": { + "properties": { + "total_processing": { + "type": "integer", + "title": "Total Processing", + "description": "处理中的任务总数" + }, + "pending": { + "type": "integer", + "title": "Pending", + "description": "待处理任务数" + }, + "voice_processing": { + "type": "integer", + "title": "Voice Processing", + "description": "音色生成中任务数" + }, + "element_processing": { + "type": "integer", + "title": "Element Processing", + "description": "主体生成中任务数" + }, + "stuck_tasks": { + "type": "integer", + "title": "Stuck Tasks", + "description": "卡住任务数(超过30分钟)" + }, + "recent_failures": { + "type": "integer", + "title": "Recent Failures", + "description": "最近1小时失败数" + } + }, + "type": "object", + "required": [ + "total_processing", + "pending", + "voice_processing", + "element_processing", + "stuck_tasks", + "recent_failures" + ], + "title": "AvatarHealthResponse", + "description": "形象克隆服务健康状态" + }, + "AvatarItem": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "形象唯一标识" + }, + "name": { + "type": "string", + "title": "Name", + "description": "展示名称" + }, + "voice_id": { + "type": "string", + "title": "Voice Id", + "description": "Kling 自定义音色 ID" + }, + "human_id": { + "type": "integer", + "title": "Human Id", + "description": "数字人主体 ID" + }, + "video_url": { + "type": "string", + "title": "Video Url", + "description": "原始人物视频 URL" + }, + "trial_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Trial Url", + "description": "音色试听 URL" + }, + "record_time": { + "type": "string", + "title": "Record Time", + "description": "创建时间 ISO 字符串" + } + }, + "type": "object", + "required": [ + "id", + "name", + "voice_id", + "human_id", + "video_url", + "record_time" + ], + "title": "AvatarItem", + "description": "形象库列表项" + }, + "AvatarTaskStatusResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "status": { + "type": "string", + "title": "Status", + "description": "当前状态" + }, + "fail_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fail Reason", + "description": "失败原因" + }, + "voice_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Voice Id", + "description": "已生成的音色 ID" + }, + "human_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Human Id", + "description": "已生成的主体 ID" + }, + "trial_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Trial Url", + "description": "试听 URL" + }, + "video_url": { + "type": "string", + "title": "Video Url", + "description": "原始视频 URL" + }, + "name": { + "type": "string", + "title": "Name", + "description": "形象名称" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "创建时间" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At", + "description": "更新时间" + } + }, + "type": "object", + "required": [ + "task_id", + "status", + "video_url", + "name", + "created_at", + "updated_at" + ], + "title": "AvatarTaskStatusResponse", + "description": "任务状态查询响应" + }, + "Body_upload_audio_api_v1_qiniu_upload_audio_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File", + "description": "音频文件(MP3, WAV, M4A, AAC, OGG)" + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Userid", + "description": "用户ID(可选,用于目录隔离)" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_audio_api_v1_qiniu_upload_audio_post" + }, + "Body_upload_avatar_api_v1_qiniu_upload_avatar_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File", + "description": "形象克隆视频(MP4, MOV)" + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Userid", + "description": "用户ID(可选,用于目录隔离)" + }, + "fileHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filehash", + "description": "前端计算的文件SHA256哈希,用于重复检测" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_avatar_api_v1_qiniu_upload_avatar_post" + }, + "Body_upload_video_api_v1_qiniu_upload_video_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File", + "description": "视频文件(MP4, MOV, AVI, WebM)" + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Userid", + "description": "用户ID(可选,用于目录隔离)" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_video_api_v1_qiniu_upload_video_post" + }, + "Body_upload_video_api_v1_video_upload_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File", + "description": "视频文件" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name", + "description": "数字人名称" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_video_api_v1_video_upload_post" + }, + "CaptionResult-Input": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码: 0=成功, 2000=处理中" + }, + "message": { + "type": "string", + "title": "Message", + "description": "状态信息" + }, + "duration": { + "type": "number", + "title": "Duration", + "description": "音频时长(秒)" + }, + "utterances": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CaptionUtterance" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Utterances", + "description": "字幕时间轴列表" + } + }, + "type": "object", + "required": [ + "code", + "message", + "duration" + ], + "title": "CaptionResult", + "description": "字幕生成结果" + }, + "CaptionResult-Output": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码: 0=成功, 2000=处理中" + }, + "message": { + "type": "string", + "title": "Message", + "description": "状态信息" + }, + "duration": { + "type": "number", + "title": "Duration", + "description": "音频时长(秒)" + }, + "utterances": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CaptionUtterance" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Utterances", + "description": "字幕时间轴列表" + } + }, + "type": "object", + "required": [ + "code", + "message", + "duration" + ], + "title": "CaptionResult", + "description": "字幕生成结果" + }, + "CaptionSubmitRequest": { + "properties": { + "audio_url": { + "type": "string", + "title": "Audio Url", + "description": "音频/视频文件URL" + }, + "language": { + "type": "string", + "title": "Language", + "description": "语言: zh-CN, en-US, ja-JP, ko-KR, es-MX, ru-RU, fr-FR, yue, wuu, nan, ug", + "default": "zh-CN" + }, + "caption_type": { + "type": "string", + "title": "Caption Type", + "description": "识别类型: auto(自动), speech(说话), singing(歌词)", + "default": "auto" + }, + "use_punc": { + "type": "boolean", + "title": "Use Punc", + "description": "自动标点: True/False", + "default": true + }, + "use_itn": { + "type": "boolean", + "title": "Use Itn", + "description": "数字转换: True(中文数字转阿拉伯数字)", + "default": true + }, + "words_per_line": { + "type": "integer", + "maximum": 100.0, + "minimum": 1.0, + "title": "Words Per Line", + "description": "每行字数", + "default": 46 + }, + "max_lines": { + "type": "integer", + "maximum": 5.0, + "minimum": 1.0, + "title": "Max Lines", + "description": "每屏行数", + "default": 1 + } + }, + "type": "object", + "required": [ + "audio_url" + ], + "title": "CaptionSubmitRequest", + "description": "字幕生成任务提交请求" + }, + "CaptionTaskResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id", + "description": "任务ID" + }, + "status": { + "type": "string", + "title": "Status", + "description": "任务状态: pending/processing/completed/failed" + } + }, + "type": "object", + "required": [ + "task_id", + "status" + ], + "title": "CaptionTaskResponse", + "description": "字幕任务提交响应" + }, + "CaptionUtterance": { + "properties": { + "text": { + "type": "string", + "title": "Text", + "description": "文本内容" + }, + "start_time": { + "type": "integer", + "title": "Start Time", + "description": "开始时间(毫秒)" + }, + "end_time": { + "type": "integer", + "title": "End Time", + "description": "结束时间(毫秒)" + }, + "words": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/CaptionWord" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Words", + "description": "字词级时间轴" + } + }, + "type": "object", + "required": [ + "text", + "start_time", + "end_time" + ], + "title": "CaptionUtterance", + "description": "一句话/一段字幕的时间轴信息" + }, + "CaptionWord": { + "properties": { + "text": { + "type": "string", + "title": "Text", + "description": "字/词内容" + }, + "start_time": { + "type": "integer", + "title": "Start Time", + "description": "开始时间(毫秒)" + }, + "end_time": { + "type": "integer", + "title": "End Time", + "description": "结束时间(毫秒)" + } + }, + "type": "object", + "required": [ + "text", + "start_time", + "end_time" + ], + "title": "CaptionWord", + "description": "单个字/词的时间轴信息" + }, + "CloneAvatarRequest": { + "properties": { + "name": { + "type": "string", + "maxLength": 20, + "minLength": 1, + "title": "Name", + "description": "形象名称" + }, + "video_url": { + "type": "string", + "title": "Video Url", + "description": "人物视频 URL" + } + }, + "type": "object", + "required": [ + "name", + "video_url" + ], + "title": "CloneAvatarRequest", + "description": "创建形象克隆请求" + }, + "CloneAvatarResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id", + "description": "任务 ID(用于 SSE/轮询跟踪进度)" + }, + "status": { + "type": "string", + "title": "Status", + "description": "初始状态", + "default": "pending" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "CloneAvatarResponse", + "description": "创建形象克隆响应" + }, + "CreateCustomVoiceRequest": { + "properties": { + "voice_name": { + "type": "string", + "title": "Voice Name", + "description": "音色名称(最多20字符)", + "example": "我的音色" + }, + "audio_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audio Url", + "description": "音频文件URL(mp3/wav/mp4/mov)" + }, + "video_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Url", + "description": "视频文件URL" + }, + "video_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Id", + "description": "历史作品ID(v2.6/sound=on/数字人/对口型生成的视频)" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + }, + "external_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Task Id", + "description": "自定义任务ID" + } + }, + "type": "object", + "required": [ + "voice_name" + ], + "title": "CreateCustomVoiceRequest", + "description": "创建自定义音色请求" + }, + "CreateCustomVoiceResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "task_status": { + "type": "string", + "title": "Task Status" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "task_id", + "task_status", + "created_at", + "updated_at" + ], + "title": "CreateCustomVoiceResponse", + "description": "创建自定义音色响应" + }, + "CreateElementRequest": { + "properties": { + "element_name": { + "type": "string", + "title": "Element Name", + "description": "主体名称(最多20字符)", + "example": "我的小猫" + }, + "element_description": { + "type": "string", + "title": "Element Description", + "description": "主体描述(最多100字符)", + "example": "一只橘色的小猫,毛茸茸的" + }, + "reference_type": { + "type": "string", + "title": "Reference Type", + "description": "参考类型: image_refer 或 video_refer", + "default": "image_refer" + }, + "element_image_list": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ElementImage" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Element Image List", + "description": "图片参考列表(图片定制时必填,第一个作为正面图)" + }, + "element_video_list": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ElementVideo" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Element Video List", + "description": "视频参考列表(视频定制时必填,第一个作为正面视频)" + }, + "element_voice_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Element Voice Id", + "description": "音色ID,绑定音色到主体" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "element_name", + "element_description" + ], + "title": "CreateElementRequest", + "description": "创建主体请求" + }, + "DigitalHuman": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "desc": { + "type": "string", + "title": "Desc" + }, + "avatar_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Avatar Url" + }, + "type": { + "type": "string", + "title": "Type", + "default": "preset" + } + }, + "type": "object", + "required": [ + "id", + "name", + "desc" + ], + "title": "DigitalHuman", + "description": "数字人信息" + }, + "ElementImage": { + "properties": { + "image_url": { + "type": "string", + "title": "Image Url", + "description": "图片URL" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name", + "description": "图片名称" + } + }, + "type": "object", + "required": [ + "image_url" + ], + "title": "ElementImage", + "description": "主体参考图片(对应 KlingAI 官方格式 imageUrl)" + }, + "ElementResponse": { + "properties": { + "element_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Element Id" + }, + "element_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Element Name" + }, + "element_description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Element Description" + }, + "element_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Element Type" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + }, + "task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + }, + "task_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Status" + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "element_image_list": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Element Image List" + }, + "element_video_list": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Element Video List" + }, + "element_voice_info": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Element Voice Info" + }, + "owned_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Owned By" + } + }, + "type": "object", + "title": "ElementResponse", + "description": "主体响应" + }, + "ElementVideo": { + "properties": { + "video_url": { + "type": "string", + "title": "Video Url", + "description": "视频URL" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name", + "description": "视频名称" + } + }, + "type": "object", + "required": [ + "video_url" + ], + "title": "ElementVideo", + "description": "主体参考视频(对应 KlingAI 官方格式 videoUrl)" + }, + "FaceChooseItem": { + "properties": { + "face_id": { + "type": "string", + "title": "Face Id", + "description": "人脸ID,由 identify-face 接口返回" + }, + "audio_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audio Id", + "description": "通过TTS生成的音频ID(与 sound_file 二选一)" + }, + "sound_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sound File", + "description": "音频文件URL或Base64(与 audio_id 二选一)" + }, + "sound_start_time": { + "type": "integer", + "title": "Sound Start Time", + "description": "音频裁剪起点时间(ms)", + "default": 0 + }, + "sound_end_time": { + "type": "integer", + "title": "Sound End Time", + "description": "音频裁剪终点时间(ms)" + }, + "sound_insert_time": { + "type": "integer", + "title": "Sound Insert Time", + "description": "裁剪后音频插入时间(ms)", + "default": 0 + } + }, + "type": "object", + "required": [ + "face_id", + "sound_end_time" + ], + "title": "FaceChooseItem", + "description": "新版对口型人脸配置" + }, + "FileUploadResponse": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "url": { + "type": "string", + "title": "Url" + }, + "hash": { + "type": "string", + "title": "Hash" + }, + "mimeType": { + "type": "string", + "title": "Mimetype" + }, + "fsize": { + "type": "integer", + "title": "Fsize" + }, + "isDuplicate": { + "type": "boolean", + "title": "Isduplicate", + "default": false + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "existingTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Existingtaskid" + } + }, + "type": "object", + "required": [ + "key", + "url", + "hash", + "mimeType", + "fsize" + ], + "title": "FileUploadResponse", + "description": "文件上传响应" + }, + "GenerateRequest": { + "properties": { + "prompt": { + "type": "string", + "title": "Prompt", + "description": "提示词" + }, + "model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Id", + "description": "指定模型 ID" + }, + "task_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Type", + "description": "任务类型,用于自动选模型: script/polish" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature", + "description": "随机性 (0-2)" + }, + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Tokens", + "description": "最大生成长度" + } + }, + "type": "object", + "required": [ + "prompt" + ], + "title": "GenerateRequest", + "description": "生成请求" + }, + "GenerateResponse": { + "properties": { + "content": { + "type": "string", + "title": "Content" + }, + "model": { + "type": "string", + "title": "Model" + }, + "usage": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Usage" + } + }, + "type": "object", + "required": [ + "content", + "model", + "usage" + ], + "title": "GenerateResponse", + "description": "生成响应" + }, + "GenerateScriptRequest": { + "properties": { + "topic": { + "type": "string", + "maxLength": 1000, + "minLength": 1, + "title": "Topic", + "description": "创作主题/灵感" + }, + "duration": { + "type": "integer", + "maximum": 180.0, + "minimum": 30.0, + "title": "Duration", + "description": "视频时长(秒)", + "default": 45 + }, + "script_type": { + "type": "string", + "title": "Script Type", + "description": "脚本类型", + "default": "干货型" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model", + "description": "指定模型(可选)" + } + }, + "type": "object", + "required": [ + "topic" + ], + "title": "GenerateScriptRequest", + "description": "生成脚本请求" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "total_models": { + "type": "integer", + "title": "Total Models" + }, + "available_models": { + "type": "integer", + "title": "Available Models" + }, + "models": { + "items": { + "type": "object" + }, + "type": "array", + "title": "Models" + } + }, + "type": "object", + "required": [ + "status", + "total_models", + "available_models", + "models" + ], + "title": "HealthResponse", + "description": "健康检查响应" + }, + "IdentifyFaceRequest": { + "properties": { + "video_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Id", + "description": "KlingAI 生成的视频 ID" + }, + "video_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Url", + "description": "上传的视频 URL(与 videoId 二选一" + } + }, + "type": "object", + "title": "IdentifyFaceRequest", + "description": "人脸识别请求" + }, + "IdentifyFaceResponse": { + "properties": { + "session_id": { + "type": "string", + "title": "Session Id" + }, + "face_data": { + "items": { + "type": "object" + }, + "type": "array", + "title": "Face Data" + } + }, + "type": "object", + "required": [ + "session_id", + "face_data" + ], + "title": "IdentifyFaceResponse", + "description": "人脸识别响应" + }, + "Image2VideoRequest": { + "properties": { + "image_url": { + "type": "string", + "title": "Image Url", + "description": "输入图片 URL" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt", + "description": "视频运动描述提示词" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model", + "description": "视频模型", + "default": "kling-v2.6" + }, + "duration": { + "type": "integer", + "maximum": 10.0, + "minimum": 5.0, + "title": "Duration", + "description": "视频时长(秒)", + "default": 5 + }, + "aspect_ratio": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Aspect Ratio", + "description": "宽高比" + }, + "mode": { + "type": "string", + "title": "Mode", + "description": "生成模式", + "default": "pro" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "image_url" + ], + "title": "Image2VideoRequest", + "description": "图生视频请求" + }, + "ImageGenerateRequest": { + "properties": { + "prompt": { + "type": "string", + "title": "Prompt", + "description": "图像描述提示词" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model", + "description": "图像模型: kolors-v1", + "default": "kolors-v1" + }, + "width": { + "type": "integer", + "title": "Width", + "description": "图像宽度", + "default": 1024 + }, + "height": { + "type": "integer", + "title": "Height", + "description": "图像高度", + "default": 1024 + }, + "negative_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Negative Prompt", + "description": "负面提示词" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "prompt" + ], + "title": "ImageGenerateRequest", + "description": "图像生成请求" + }, + "LoginResponse": { + "properties": { + "token": { + "type": "string", + "title": "Token", + "description": "JWT 访问令牌" + }, + "user": { + "$ref": "#/components/schemas/UserInfo" + } + }, + "type": "object", + "required": [ + "token", + "user" + ], + "title": "LoginResponse", + "description": "登录响应" + }, + "MobileLoginRequest": { + "properties": { + "mobile": { + "type": "string", + "maxLength": 20, + "minLength": 11, + "title": "Mobile", + "description": "手机号" + }, + "nickname": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "Nickname", + "description": "用户昵称" + } + }, + "type": "object", + "required": [ + "mobile" + ], + "title": "MobileLoginRequest", + "description": "手机号登录请求" + }, + "ModelHealthInfo": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "模型 ID" + }, + "name": { + "type": "string", + "title": "Name", + "description": "模型名称" + }, + "is_available": { + "type": "boolean", + "title": "Is Available", + "description": "是否可用" + }, + "response_time": { + "type": "number", + "title": "Response Time", + "description": "响应时间(毫秒)" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error", + "description": "上次错误信息" + } + }, + "type": "object", + "required": [ + "id", + "name", + "is_available", + "response_time" + ], + "title": "ModelHealthInfo", + "description": "模型健康信息" + }, + "ModelHealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status", + "description": "整体状态:healthy / unhealthy / error" + }, + "models": { + "items": { + "$ref": "#/components/schemas/ModelHealthInfo" + }, + "type": "array", + "title": "Models", + "description": "各模型状态" + }, + "recommended_model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelHealthInfo" + }, + { + "type": "null" + } + ], + "description": "推荐的模型" + }, + "total_models": { + "type": "integer", + "title": "Total Models", + "description": "模型总数" + }, + "available_models": { + "type": "integer", + "title": "Available Models", + "description": "可用模型数" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "错误信息" + } + }, + "type": "object", + "required": [ + "status", + "models", + "total_models", + "available_models" + ], + "title": "ModelHealthResponse", + "description": "模型健康检查响应" + }, + "ModelResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "platform_id": { + "type": "string", + "title": "Platform Id" + }, + "model_name": { + "type": "string", + "title": "Model Name" + }, + "display_name": { + "type": "string", + "title": "Display Name" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Capabilities" + }, + "default_params": { + "type": "object", + "title": "Default Params" + }, + "is_enabled": { + "type": "boolean", + "title": "Is Enabled" + }, + "full_model_id": { + "type": "string", + "title": "Full Model Id" + } + }, + "type": "object", + "required": [ + "id", + "platform_id", + "model_name", + "display_name", + "capabilities", + "default_params", + "is_enabled", + "full_model_id" + ], + "title": "ModelResponse", + "description": "模型响应" + }, + "OmniImageRequest": { + "properties": { + "prompt": { + "type": "string", + "title": "Prompt", + "description": "图像描述提示词" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model", + "description": "模型: kling-image-o1, kling-v3-omni", + "default": "kling-image-o1" + }, + "aspect_ratio": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Aspect Ratio", + "description": "宽高比: 16:9/9:16/1:1/4:3/3:4", + "default": "9:16" + }, + "resolution": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resolution", + "description": "清晰度: 1k/2k/4k", + "default": "1k" + }, + "result_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Result Type", + "description": "结果类型: single/series", + "default": "single" + }, + "n": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "N", + "description": "生成数量 1-9", + "default": 1 + }, + "element_list": { + "anyOf": [ + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Element List", + "description": "主体参考列表" + }, + "image_list": { + "anyOf": [ + { + "items": { + "type": "object" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Image List", + "description": "参考图列表" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "prompt" + ], + "title": "OmniImageRequest", + "description": "Omni-Image 图像生成请求" + }, + "OmniVideoRequest": { + "properties": { + "prompt": { + "type": "string", + "title": "Prompt", + "description": "视频描述提示词(不超过2500字符),支持 <<>>/<<>>/<<>> 引用语法", + "example": "一只<<>>在花园里奔跑,阳光明媚" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model", + "description": "模型: kling-v3-omni, kling-video-o1", + "default": "kling-v3-omni" + }, + "duration": { + "type": "integer", + "maximum": 15.0, + "minimum": 3.0, + "title": "Duration", + "description": "视频时长(秒): 3/5/10/15,Omni支持3-15秒", + "default": 5 + }, + "aspect_ratio": { + "type": "string", + "title": "Aspect Ratio", + "description": "宽高比: 16:9, 9:16, 1:1", + "default": "16:9" + }, + "mode": { + "type": "string", + "title": "Mode", + "description": "生成模式: pro(高质量) 或 std(标准)", + "default": "pro" + }, + "sound": { + "type": "string", + "title": "Sound", + "description": "声音控制: on=音画同出, off=无声", + "default": "on" + }, + "negative_prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Negative Prompt", + "description": "负面提示词" + }, + "multi_shot": { + "type": "boolean", + "title": "Multi Shot", + "description": "是否启用多镜头模式", + "default": false + }, + "shot_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Shot Type", + "description": "分镜方式: customize=自定义, intelligence=智能分镜" + }, + "multi_prompt": { + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "type": "array", + "title": "Multi Prompt", + "description": "多镜头提示词列表,每个元素包含 index, prompt, duration,最多6个分镜" + }, + "image_list": { + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "type": "array", + "title": "Image List", + "description": "参考图片列表,最多4张" + }, + "element_list": { + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "type": "array", + "title": "Element List", + "description": "主体参考列表,格式: [{'elementId': 123}], 最多7个" + }, + "video_list": { + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "type": "array", + "title": "Video List", + "description": "参考视频列表" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + }, + "external_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Task Id", + "description": "自定义任务ID" + } + }, + "type": "object", + "required": [ + "prompt" + ], + "title": "OmniVideoRequest", + "description": "Omni-Video 视频生成请求(kling-v3-omni / kling-video-o1)" + }, + "PlatformResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "provider": { + "type": "string", + "title": "Provider" + } + }, + "type": "object", + "required": [ + "id", + "name", + "provider" + ], + "title": "PlatformResponse", + "description": "平台响应" + }, + "PolishResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "original": { + "type": "string", + "title": "Original" + }, + "polished": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Polished" + }, + "polish_type": { + "type": "string", + "title": "Polish Type" + }, + "model": { + "type": "string", + "title": "Model" + }, + "usage": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Usage" + } + }, + "type": "object", + "required": [ + "success", + "original", + "polished", + "polish_type", + "model", + "usage" + ], + "title": "PolishResponse", + "description": "润色响应" + }, + "PromptTemplatesResponse": { + "properties": { + "script_types": { + "items": { + "type": "object" + }, + "type": "array", + "title": "Script Types" + }, + "video_styles": { + "items": { + "type": "object" + }, + "type": "array", + "title": "Video Styles" + }, + "tones": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tones" + } + }, + "type": "object", + "required": [ + "script_types", + "video_styles", + "tones" + ], + "title": "PromptTemplatesResponse", + "description": "Prompt 模板配置响应" + }, + "ScriptGenerateRequest": { + "properties": { + "topic": { + "type": "string", + "title": "Topic", + "description": "脚本主题", + "example": "水电改造的3个致命错误" + }, + "duration": { + "type": "integer", + "maximum": 120.0, + "minimum": 15.0, + "title": "Duration", + "description": "视频时长(秒)", + "default": 30 + }, + "script_type": { + "type": "string", + "title": "Script Type", + "description": "脚本类型", + "default": "干货型" + }, + "video_style": { + "type": "string", + "title": "Video Style", + "description": "视频风格", + "default": "口播" + }, + "tone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tone", + "description": "语气风格" + }, + "requirements": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Requirements", + "description": "额外要求" + }, + "model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Id", + "description": "指定模型ID,默认使用系统默认模型" + } + }, + "type": "object", + "required": [ + "topic" + ], + "title": "ScriptGenerateRequest", + "description": "脚本生成请求" + }, + "ScriptGenerateResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success" + }, + "script": { + "items": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "type": "array", + "title": "Script" + }, + "total_duration": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Duration" + }, + "target_duration": { + "type": "integer", + "title": "Target Duration" + }, + "total_word_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Word Count" + }, + "segment_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Segment Count" + }, + "empty_shot_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Empty Shot Count" + }, + "script_type": { + "type": "string", + "title": "Script Type" + }, + "model": { + "type": "string", + "title": "Model" + }, + "usage": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Usage" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "raw_content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Raw Content" + } + }, + "type": "object", + "required": [ + "success", + "script", + "total_duration", + "target_duration", + "total_word_count", + "segment_count", + "empty_shot_count", + "script_type", + "model", + "usage", + "error", + "raw_content" + ], + "title": "ScriptGenerateResponse", + "description": "脚本生成响应 - 针对前端展示优化" + }, + "Segment": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "分镜ID" + }, + "type": { + "type": "string", + "enum": [ + "segment", + "empty_shot" + ], + "title": "Type", + "description": "分镜类型: segment(分镜) 或 empty_shot(空镜)", + "default": "segment" + }, + "scene": { + "type": "string", + "title": "Scene", + "description": "场景描述/画面描述", + "default": "" + }, + "voiceover": { + "type": "string", + "title": "Voiceover", + "description": "配音文案(空镜可为空)", + "default": "" + }, + "duration": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Duration", + "description": "时长(秒)" + }, + "human_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Human Id", + "description": "数字人主体ID" + }, + "voice_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Voice Id", + "description": "音色ID(空镜时使用)" + }, + "status": { + "$ref": "#/components/schemas/SegmentStatus", + "default": "pending" + }, + "provider_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider Task Id", + "description": "供应商任务ID(如 Kling task_id)" + }, + "video_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Url", + "description": "生成后的视频URL" + }, + "local_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Local Path", + "description": "本地视频路径" + }, + "qiniu_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Qiniu Url", + "description": "七牛云URL" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message", + "description": "错误信息" + }, + "stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Stage", + "description": "内部处理阶段(如 image_generating / video_processing)" + }, + "image_task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image Task Id", + "description": "空镜文生图任务ID(内部使用)" + }, + "query_fail_count": { + "type": "integer", + "title": "Query Fail Count", + "description": "查询失败计数", + "default": 0 + } + }, + "type": "object", + "required": [ + "id" + ], + "title": "Segment", + "description": "视频分镜/镜头定义\n\n术语说明:\n- segment: 分镜(带数字人)\n- empty_shot: 空镜(无数字人)" + }, + "SegmentStatus": { + "type": "string", + "enum": [ + "pending", + "submitted", + "processing", + "completed", + "failed" + ], + "title": "SegmentStatus", + "description": "视频分镜处理状态" + }, + "ShotResult": { + "properties": { + "segment_id": { + "type": "string", + "title": "Segment Id" + }, + "type": { + "type": "string", + "title": "Type" + }, + "status": { + "type": "string", + "title": "Status" + }, + "task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + }, + "video_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Url" + }, + "local_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Local Path" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message" + } + }, + "type": "object", + "required": [ + "segment_id", + "type", + "status" + ], + "title": "ShotResult", + "description": "单个分镜结果" + }, + "SrtSubtitleResponse": { + "properties": { + "srt_content": { + "type": "string", + "title": "Srt Content", + "description": "SRT 格式字幕内容" + }, + "utterances": { + "items": { + "$ref": "#/components/schemas/CaptionUtterance" + }, + "type": "array", + "title": "Utterances", + "description": "原始时间轴数据" + } + }, + "type": "object", + "required": [ + "srt_content", + "utterances" + ], + "title": "SrtSubtitleResponse", + "description": "SRT 字幕格式响应" + }, + "TaskCreateRequest": { + "properties": { + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Id", + "description": "项目ID(可选)" + }, + "params": { + "type": "object", + "title": "Params", + "description": "任务参数" + } + }, + "type": "object", + "title": "TaskCreateRequest", + "description": "创建任务请求" + }, + "TaskCreateResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id", + "description": "任务ID" + }, + "status": { + "type": "string", + "title": "Status", + "description": "任务状态", + "default": "pending" + }, + "message": { + "type": "string", + "title": "Message", + "description": "状态消息", + "default": "任务已创建" + } + }, + "type": "object", + "required": [ + "task_id" + ], + "title": "TaskCreateResponse", + "description": "创建任务响应" + }, + "TestModelRequest": { + "properties": { + "model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Id", + "description": "要测试的模型 ID" + } + }, + "type": "object", + "title": "TestModelRequest", + "description": "测试模型请求" + }, + "TestModelResponse": { + "properties": { + "success": { + "type": "boolean", + "title": "Success", + "description": "是否成功" + }, + "model": { + "type": "string", + "title": "Model", + "description": "模型名称" + }, + "response_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Response Time", + "description": "响应时间(毫秒)" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "错误信息" + }, + "checked_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Checked At", + "description": "检查时间 ISO 格式" + } + }, + "type": "object", + "required": [ + "success", + "model" + ], + "title": "TestModelResponse", + "description": "测试模型响应" + }, + "UpdateAvatarNameRequest": { + "properties": { + "name": { + "type": "string", + "maxLength": 20, + "minLength": 1, + "title": "Name", + "description": "新形象名称" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "UpdateAvatarNameRequest", + "description": "更新形象名称请求" + }, + "UploadTokenRequest": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "文件存储 Key" + }, + "expires": { + "type": "integer", + "title": "Expires", + "description": "Token 有效期(秒)", + "default": 3600 + } + }, + "type": "object", + "required": [ + "key" + ], + "title": "UploadTokenRequest", + "description": "上传凭证请求" + }, + "UploadTokenResponse": { + "properties": { + "token": { + "type": "string", + "title": "Token" + }, + "key": { + "type": "string", + "title": "Key" + }, + "uploadUrl": { + "type": "string", + "title": "Uploadurl", + "default": "https://upload.qiniup.com" + } + }, + "type": "object", + "required": [ + "token", + "key" + ], + "title": "UploadTokenResponse", + "description": "上传凭证响应" + }, + "UserInfo": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "description": "用户 ID" + }, + "nickname": { + "type": "string", + "title": "Nickname", + "description": "用户昵称" + }, + "avatar": { + "type": "string", + "title": "Avatar", + "description": "头像 URL", + "default": "" + } + }, + "type": "object", + "required": [ + "id", + "nickname" + ], + "title": "UserInfo", + "description": "用户信息" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "VideoGenerateRequest": { + "properties": { + "project_id": { + "type": "string", + "title": "Project Id", + "description": "项目ID" + }, + "human_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Human Id", + "description": "数字人主体ID(分镜类型使用)" + }, + "segments": { + "items": { + "$ref": "#/components/schemas/Segment" + }, + "type": "array", + "title": "Segments", + "description": "分镜列表" + } + }, + "type": "object", + "required": [ + "project_id", + "segments" + ], + "title": "VideoGenerateRequest", + "description": "视频生成请求" + }, + "VideoJobDetail": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id" + }, + "project_id": { + "type": "string", + "title": "Project Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "progress": { + "type": "integer", + "title": "Progress" + }, + "total_segments": { + "type": "integer", + "title": "Total Segments" + }, + "completed_segments": { + "type": "integer", + "title": "Completed Segments" + }, + "failed_segments": { + "type": "integer", + "title": "Failed Segments" + }, + "segments": { + "items": { + "$ref": "#/components/schemas/ShotResult" + }, + "type": "array", + "title": "Segments" + }, + "created_at": { + "type": "number", + "title": "Created At" + }, + "updated_at": { + "type": "number", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "job_id", + "project_id", + "status", + "progress", + "total_segments", + "completed_segments", + "failed_segments", + "segments", + "created_at", + "updated_at" + ], + "title": "VideoJobDetail", + "description": "视频作业详情" + }, + "VideoJobStatus": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id" + }, + "project_id": { + "type": "string", + "title": "Project Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "progress": { + "type": "integer", + "title": "Progress" + }, + "total_segments": { + "type": "integer", + "title": "Total Segments" + }, + "completed_segments": { + "type": "integer", + "title": "Completed Segments" + }, + "failed_segments": { + "type": "integer", + "title": "Failed Segments" + }, + "created_at": { + "type": "number", + "title": "Created At" + }, + "updated_at": { + "type": "number", + "title": "Updated At" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message" + } + }, + "type": "object", + "required": [ + "job_id", + "project_id", + "status", + "progress", + "total_segments", + "completed_segments", + "failed_segments", + "created_at", + "updated_at" + ], + "title": "VideoJobStatus", + "description": "视频作业状态" + }, + "VirtualTryonRequest": { + "properties": { + "person_image_url": { + "type": "string", + "title": "Person Image Url", + "description": "人物图片 URL" + }, + "cloth_image_url": { + "type": "string", + "title": "Cloth Image Url", + "description": "衣服图片 URL" + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "回调通知地址" + } + }, + "type": "object", + "required": [ + "person_image_url", + "cloth_image_url" + ], + "title": "VirtualTryonRequest", + "description": "虚拟试穿请求" + }, + "VoiceInfo": { + "properties": { + "voice_id": { + "type": "string", + "title": "Voice Id" + }, + "voice_name": { + "type": "string", + "title": "Voice Name" + }, + "trial_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Trial Url" + }, + "owned_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Owned By" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "type": "object", + "required": [ + "voice_id", + "voice_name" + ], + "title": "VoiceInfo", + "description": "音色信息" + }, + "app__api__v1__ai_models__PolishRequest": { + "properties": { + "content": { + "type": "string", + "title": "Content", + "description": "需要润色的内容" + }, + "polish_type": { + "type": "string", + "title": "Polish Type", + "description": "润色类型:scene/voiceover", + "default": "voiceover" + }, + "model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Id", + "description": "指定模型ID" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "PolishRequest", + "description": "润色请求" + }, + "app__api__v1__klingai__TaskStatusResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "task_status": { + "type": "string", + "title": "Task Status" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + }, + "video_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Video Url" + }, + "image_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image Url" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message" + } + }, + "type": "object", + "required": [ + "task_id", + "task_status", + "created_at", + "updated_at" + ], + "title": "TaskStatusResponse", + "description": "任务状态响应" + }, + "app__api__v1__klingai__VideoGenerateResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "task_status": { + "type": "string", + "title": "Task Status" + }, + "created_at": { + "type": "integer", + "title": "Created At" + }, + "updated_at": { + "type": "integer", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "task_id", + "task_status", + "created_at", + "updated_at" + ], + "title": "VideoGenerateResponse", + "description": "视频生成响应" + }, + "app__api__v1__tasks__TaskStatusResponse": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id", + "description": "任务ID" + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type", + "description": "任务类型" + }, + "status": { + "type": "string", + "title": "Status", + "description": "任务状态: pending/running/waiting/completed/failed" + }, + "progress": { + "type": "integer", + "title": "Progress", + "description": "进度百分比 (0-100)", + "default": 0 + }, + "message": { + "type": "string", + "title": "Message", + "description": "状态描述", + "default": "" + }, + "completed": { + "type": "integer", + "title": "Completed", + "description": "已完成子任务数", + "default": 0 + }, + "total": { + "type": "integer", + "title": "Total", + "description": "总子任务数", + "default": 0 + }, + "result": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Result", + "description": "任务结果(完成时)" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "错误信息(失败时)" + } + }, + "type": "object", + "required": [ + "task_id", + "status" + ], + "title": "TaskStatusResponse", + "description": "任务状态响应" + }, + "app__api__v1__video__VideoGenerateResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "作业ID" + }, + "task_id": { + "type": "string", + "title": "Task Id", + "description": "任务ID(与job_id相同,兼容前端)" + }, + "status": { + "type": "string", + "title": "Status", + "description": "作业状态" + }, + "message": { + "type": "string", + "title": "Message", + "description": "状态消息" + }, + "sse_url": { + "type": "string", + "title": "Sse Url", + "description": "SSE进度流URL" + } + }, + "type": "object", + "required": [ + "job_id", + "task_id", + "status", + "message", + "sse_url" + ], + "title": "VideoGenerateResponse", + "description": "视频生成响应" + }, + "app__schemas__common__ApiResponse_VideoGenerateResponse___1": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/app__api__v1__klingai__VideoGenerateResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[VideoGenerateResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "app__schemas__common__ApiResponse_VideoGenerateResponse___2": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "description": "状态码,200 表示成功", + "default": 200 + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/app__api__v1__video__VideoGenerateResponse" + }, + { + "type": "null" + } + ], + "description": "响应数据" + }, + "message": { + "type": "string", + "title": "Message", + "description": "提示信息", + "default": "success" + } + }, + "type": "object", + "title": "ApiResponse[VideoGenerateResponse]", + "example": { + "code": 200, + "data": {}, + "message": "success" + } + }, + "app__schemas__script__PolishRequest": { + "properties": { + "content": { + "type": "string", + "minLength": 1, + "title": "Content", + "description": "待润色内容" + }, + "polish_type": { + "type": "string", + "title": "Polish Type", + "description": "润色类型:scene / voiceover", + "default": "voiceover" + }, + "shot_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Shot Type", + "description": "镜头类型:segment(分镜) / empty_shot(空镜),用于画面润色时区分", + "default": "segment" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "PolishRequest", + "description": "润色请求" + } + }, + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + } + } +} diff --git a/tauri-app/src/api/generated/schema.ts b/tauri-app/src/api/generated/schema.ts new file mode 100644 index 0000000..13d0166 --- /dev/null +++ b/tauri-app/src/api/generated/schema.ts @@ -0,0 +1,7148 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Login + * @description 手机号登录/注册 + * + * - 如果手机号已存在,返回对应用户 + * - 如果不存在,自动创建新用户 + * - 返回 JWT Token 用于后续认证 + */ + post: operations["login_api_v1_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Me + * @description 获取当前登录用户信息 + */ + get: operations["get_me_api_v1_auth_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refresh Token + * @description 刷新 JWT Token + */ + post: operations["refresh_token_api_v1_auth_refresh_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/script/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Script + * @description 同步生成脚本 + * + * 直接返回生成的分镜列表,适合快速预览。 + */ + post: operations["generate_script_api_v1_script_generate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/script/generate/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Script Stream + * @description 流式生成脚本(SSE) + * + * 返回 Server-Sent Events,包含进度更新和最终结果。 + * 前端通过 EventSource 接收实时进度。 + * + * **SSE 事件类型:** + * - `start`: 开始生成 + * - `analyzing`: 分析主题 + * - `planning`: 规划结构 + * - `generating`: AI 生成中 + * - `parsing`: 解析结果 + * - `complete`: 完成,包含 result 字段 + * - `error`: 错误 + * + * **示例事件流:** + * ``` + * data: {"type": "start", "progress": 0, "message": "开始生成脚本"} + * + * data: {"type": "analyzing", "progress": 15, "message": "分析目标受众..."} + * + * data: {"type": "complete", "progress": 100, "message": "成功生成 5 个分镜", "result": [...]} + * ``` + */ + post: operations["generate_script_stream_api_v1_script_generate_stream_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/script/polish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Polish Content + * @description AI 润色文案/画面描述 + * + * - `polishType=scene`: 润色画面描述(根据 shot_type 自动区分分镜/空镜) + * - `polishType=voiceover`: 润色配音文案 + * + * 参数: + * - `shot_type`: "segment"(分镜)或 "empty_shot"(空镜),画面润色时必填 + */ + post: operations["polish_content_api_v1_script_polish_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/script/model-health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check Model Health + * @description 检查 AI 模型健康状态 + * + * 返回所有配置的模型及其可用性状态。 + */ + get: operations["check_model_health_api_v1_script_model_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/script/test-model": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Test Model + * @description 测试指定模型连接 + * + * 发送一个简单的测试请求,验证模型是否可用。 + */ + post: operations["test_model_api_v1_script_test_model_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/platforms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Platforms + * @description 获取所有平台列表 + */ + get: operations["list_platforms_api_v1_ai_platforms_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Models + * @description 获取模型列表 + * + * Args: + * capability: 按能力过滤,如 script、polish、chat + */ + get: operations["list_models_api_v1_ai_models_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Text + * @description 文本生成(自动路由到对应平台) + */ + post: operations["generate_text_api_v1_ai_generate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health Check + * @description 检查模型健康状态 + */ + get: operations["health_check_api_v1_ai_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/platforms/{platform_id}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Test Platform Connection + * @description 测试平台连接 + */ + get: operations["test_platform_connection_api_v1_ai_platforms__platform_id__test_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/reload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reload Config + * @description 重新加载配置文件 + */ + post: operations["reload_config_api_v1_ai_reload_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/prompts/templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Prompt Templates + * @description 获取所有可用的 Prompt 模板配置 + * + * 包括脚本类型、视频风格、语气风格等选项。 + */ + get: operations["get_prompt_templates_api_v1_ai_prompts_templates_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/prompts/build": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Build System Prompt + * @description 构建系统 Prompt(用于调试和预览) + * + * 返回构建好的系统 Prompt,可用于前端预览或调试。 + */ + post: operations["build_system_prompt_api_v1_ai_prompts_build_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/scripts/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Script + * @description 生成家装行业短视频脚本 + * + * 使用专业的 Prompt 模板生成包含分镜+空镜的混合脚本。 + * 针对前端展示优化,返回分镜数、空镜数、总字数等统计信息。 + */ + post: operations["generate_script_api_v1_ai_scripts_generate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/ai/scripts/polish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Polish Script Content + * @description 润色脚本内容 + * + * 对场景描述或口播文案进行专业润色。 + */ + post: operations["polish_script_content_api_v1_ai_scripts_polish_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/omni": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Omni Video Tasks + * @description 查询 Omni-Video 任务列表 + * + * 查询历史 Omni-Video 任务列表。 + */ + get: operations["list_omni_video_tasks_api_v1_klingai_videos_omni_get"]; + put?: never; + /** + * Create Omni Video + * @description Omni-Video 多模态视频生成 + * + * 支持文本、图片、主体、视频等多种输入方式组合生成视频。 + * 适用于 kling-v3-omni 和 kling-video-o1 模型。 + * + * **特性:** + * - 支持 3-15 秒视频生成 + * - 支持多镜头和智能分镜 (shotType=intelligence) + * - 支持引用主体、图片、视频作为参考 + * - 支持 <<>>/<<>>/<<>> 语法引用提示词中的资源 + */ + post: operations["create_omni_video_api_v1_klingai_videos_omni_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/omni/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Omni Video Task + * @description 查询 Omni-Video 任务状态 + * + * 查询指定 Omni-Video 任务的执行状态和结果。 + */ + get: operations["get_omni_video_task_api_v1_klingai_videos_omni__task_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/image2video": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Image To Video + * @description 图生视频 + * + * 根据输入图片生成视频。 + */ + post: operations["create_image_to_video_api_v1_klingai_videos_image2video_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/extend": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Extend Video + * @description 视频延长 + * + * 延长现有视频的时长。 + */ + post: operations["extend_video_api_v1_klingai_videos_extend_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/identify-face": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Identify Face + * @description 对口型前置:人脸识别 + * + * 分析视频中的人脸信息,返回 sessionId 和 faceId,用于后续的 advanced-lip-sync。 + */ + post: operations["identify_face_api_v1_klingai_videos_identify_face_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/advanced-lip-sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Advanced Lip Sync + * @description 新版对口型视频生成 + * + * 基于 KlingAI advanced-lip-sync 接口,先调用 /videos/identify-face 获取 sessionId 和 faceId, + * 再传入本接口生成对口型视频。 + * + * 支持 audio_id(TTS 生成)或 soundFile(外部音频 URL)驱动口型。 + */ + post: operations["create_advanced_lip_sync_api_v1_klingai_videos_advanced_lip_sync_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/videos/advanced-lip-sync/{taskId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Advanced Lip Sync Task + * @description 查询新版对口型任务状态 + */ + get: operations["get_advanced_lip_sync_task_api_v1_klingai_videos_advanced_lip_sync__taskId__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/images/omni": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Omni Image + * @description Omni-Image 图像生成 + * + * 支持文本、主体、参考图等多种输入方式组合生成图像。 + */ + post: operations["create_omni_image_api_v1_klingai_images_omni_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/images/omni/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Omni Image Task + * @description 查询 Omni-Image 任务状态 + */ + get: operations["get_omni_image_task_api_v1_klingai_images_omni__task_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/images/generations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Image + * @description 文生图 + * + * 根据文本描述生成图像。 + */ + post: operations["create_image_api_v1_klingai_images_generations_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/virtual-tryon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Virtual Tryon + * @description 虚拟试穿 + * + * 将衣服虚拟试穿到人物身上。 + */ + post: operations["create_virtual_tryon_api_v1_klingai_virtual_tryon_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/tasks/{taskId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Taskstatus + * @description 查询任务状态 + * + * 查询指定任务的执行状态和结果。 + * + * Args: + * taskId: 任务 ID + * task_type: 任务类型 (video, image2video, image, lip-sync, virtual-tryon) + */ + get: operations["get_taskStatus_api_v1_klingai_tasks__taskId__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/tasks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Tasks + * @description 查询任务列表 + * + * 查询历史任务列表。 + */ + get: operations["list_tasks_api_v1_klingai_tasks_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/elements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Elements + * @description 查询主体列表 + * + * 获取所有已创建的主体(自定义元素)列表。 + */ + get: operations["list_elements_api_v1_klingai_elements_get"]; + put?: never; + /** + * Create Element + * @description 创建主体(自定义元素) + * + * 通过上传图片或视频创建可复用的主体,用于视频/图像生成时保持角色一致性。 + * + * 图片要求: + * - 格式:jpg, jpeg, png + * - 大小:≤10MB + * - 数量:正面图 + 1-3张其他角度 + * + * 视频要求: + * - 格式:mp4, mov + * - 时长:3-8秒 + * - 分辨率:1080P + * - 大小:≤200MB + */ + post: operations["create_element_api_v1_klingai_elements_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/elements/{elementId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Element + * @description 查询单个主体详情 + * + * 获取指定主体的详细信息。 + */ + get: operations["get_element_api_v1_klingai_elements__elementId__get"]; + put?: never; + post?: never; + /** + * Delete Element + * @description 删除主体 + * + * 删除不再使用的主体(自定义元素)。 + */ + delete: operations["delete_element_api_v1_klingai_elements__elementId__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/elements/ai-multi-shot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Ai Multishot + * @description 智能补全主体不同角度图片 + * + * 通过主体正面图,自动推理出该主体其他角度图片。 + * 每次可生成3组结果供选择,每次扣减0.5积分。 + * + * 使用流程: + * 1. 调用此接口传入正面图 + * 2. 轮询查询任务状态 + * 3. 获取生成的多组角度图片 + * 4. 选择合适的图片创建主体 + */ + post: operations["ai_multiShot_api_v1_klingai_elements_ai_multi_shot_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/elements/ai-multi-shot/{taskId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Ai Multishot Task + * @description 查询智能补全主体图任务状态 + * + * 获取指定任务的执行状态和生成的多角度图片结果。 + */ + get: operations["get_ai_multiShot_task_api_v1_klingai_elements_ai_multi_shot__taskId__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/voices/custom": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Custom Voices + * @description 查询自定义音色列表 + * + * 获取所有已创建的自定义音色。 + */ + get: operations["list_custom_voices_api_v1_klingai_voices_custom_get"]; + put?: never; + /** + * Create Custom Voice + * @description 创建自定义音色 + * + * 通过上传音频文件或引用历史视频创建自定义音色,用于对口型视频。 + * + * 音频要求: + * - 格式:mp3, wav, mp4, mov + * - 时长:5-30 秒 + * - 人声干净、无杂音、单一人声 + */ + post: operations["create_custom_voice_api_v1_klingai_voices_custom_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/voices/custom/{voiceId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Custom Voice + * @description 查询单个自定义音色 + * + * 获取指定自定义音色的详细信息。 + */ + get: operations["get_custom_voice_api_v1_klingai_voices_custom__voiceId__get"]; + put?: never; + post?: never; + /** + * Delete Custom Voice + * @description 删除自定义音色 + * + * 删除不再使用的自定义音色。 + */ + delete: operations["delete_custom_voice_api_v1_klingai_voices_custom__voiceId__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/klingai/voices/presets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Preset Voices + * @description 查询官方预设音色列表 + * + * 获取 KlingAI 提供的官方音色列表。 + */ + get: operations["list_preset_voices_api_v1_klingai_voices_presets_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/qiniu/upload-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Upload Token + * @description 获取上传凭证(客户端直传) + * + * 前端获取 Token 后,可直接上传到七牛云,无需经过服务端。 + * + * 上传地址: https://upload.qiniup.com + * 请求方式: POST (multipart/form-data) + * 请求参数: + * - token: 上传凭证(本接口返回) + * - key: 文件存储 Key(本接口返回) + * - file: 文件内容 + */ + post: operations["get_upload_token_api_v1_qiniu_upload_token_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/qiniu/upload/audio": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Audio + * @description 上传音频文件 + * + * 支持格式: MP3, WAV, M4A, AAC, OGG + * 文件会自动存储到: audios/{userId}/{date}/{uuid}.{ext} + */ + post: operations["upload_audio_api_v1_qiniu_upload_audio_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/qiniu/upload/video": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Video + * @description 上传视频文件 + * + * 支持格式: MP4, MOV, AVI, WebM + * 文件会自动存储到: videos/{userId}/{date}/{uuid}.{ext} + */ + post: operations["upload_video_api_v1_qiniu_upload_video_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/qiniu/upload/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Avatar + * @description 上传形象克隆视频 + * + * 用于形象克隆功能,上传的视频将同时用于创建自定义音色和主体。 + * + * KlingAI 要求: + * - 格式: MP4, MOV + * - 时长: 5-30 秒 (建议 5-8 秒) + * - 大小: 不超过 200MB + * - 分辨率: 高度 720px~2160px + * - 内容: 写实风格人物正面特写,人脸清晰、无遮挡,视频中有清晰人声 + * + * 文件存储路径: meijiaka/avatars/{userId}/{date}/{uuid}.{ext} + * + * 重复检测: + * - 如果提供了 fileHash,会检查是否已有相同文件的任务在进行中 + * - 返回的 isDuplicate 表示是否复用了已有资源 + * - existingTaskId 表示已存在任务的ID(如果有) + */ + post: operations["upload_avatar_api_v1_qiniu_upload_avatar_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/qiniu/files/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get File Info + * @description 获取文件信息 + * + * Args: + * key: 文件存储 Key(路径格式) + */ + get: operations["get_file_info_api_v1_qiniu_files__key__get"]; + put?: never; + post?: never; + /** + * Delete File + * @description 删除文件 + * + * Args: + * key: 文件存储 Key + */ + delete: operations["delete_file_api_v1_qiniu_files__key__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/qiniu/refresh-cdn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Refresh Cdn + * @description 刷新 CDN 缓存 + * + * 文件更新后,调用此接口刷新 CDN 缓存,确保用户访问到最新内容。 + */ + post: operations["refresh_cdn_api_v1_qiniu_refresh_cdn_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Video Generation + * @description 创建视频生成任务 + * + * 接收项目ID、数字人ID和分镜列表,创建视频生成作业。 + * 支持 SSE 流式查询进度。 + * + * **分镜类型说明:** + * - `segment`: 分镜(带数字人),使用 omni-video 接口,需要 element_id + * - `empty_shot`: 空镜,使用文生图 + 图生视频流程 + * + * **调用流程:** + * 1. 调用此接口创建任务,获取 job_id + * 2. 使用 SSE 接口 `/video/jobs/{job_id}/stream` 监听进度 + * 3. 或使用 `/video/jobs/{job_id}` 查询状态 + */ + post: operations["create_video_generation_api_v1_video_generate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/jobs/{job_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Video Job + * @description 查询视频生成作业详情 + * + * 获取指定作业的详细信息和所有分镜的处理结果。 + */ + get: operations["get_video_job_api_v1_video_jobs__job_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/jobs/{job_id}/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Stream Video Job + * @description 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); + * }; + * ``` + */ + get: operations["stream_video_job_api_v1_video_jobs__job_id__stream_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/jobs/{job_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Video Job Status + * @description 获取视频生成作业状态(简化版) + */ + get: operations["get_video_job_status_api_v1_video_jobs__job_id__status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Digital Humans + * @description 获取数字人素材库 + * + * 返回系统预设的数字人列表。 + */ + get: operations["get_digital_humans_api_v1_video_library_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Video + * @description 上传人物视频作为数字人素材 + * + * 文件要求: + * - 格式:mp4, mov + * - 时长:2-60秒 + * - 分辨率:720p 或 1080p + */ + post: operations["upload_video_api_v1_video_upload_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/{video_id}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download Video + * @description 下载视频文件 + * + * 支持三种查找位置: + * 1. data/video/{video_id}.mp4 - 传统存储 + * 2. data/uploads/{video_id}.ext - 上传文件 + * 3. ~/Documents/Meijiaka/projects/*\/videos/{video_id}.mp4 - 项目生成的视频 + * 文件名格式: scene_{shot_id}.mp4 + */ + get: operations["download_video_api_v1_video__video_id__download_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/video/{video_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Video Thumbnail + * @description 获取视频缩略图 + */ + get: operations["get_video_thumbnail_api_v1_video__video_id__thumbnail_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/clone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Clone Avatar + * @description 提交形象克隆任务 + * + * 立即返回 task_id,前端通过 SSE 或轮询跟踪进度。 + * 实际串行流程由 Celery Worker 异步执行。 + */ + post: operations["clone_avatar_api_v1_avatar_clone_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/tasks/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Avatar Task Status + * @description 查询形象克隆任务状态 + */ + get: operations["get_avatar_task_status_api_v1_avatar_tasks__task_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/clone/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Sse Avatar Clone + * @description SSE 流:实时推送形象克隆任务状态 + * + * 前端连接后,每 3 秒推送一次状态,直到任务结束(succeed / failed / timeout)。 + */ + get: operations["sse_avatar_clone_api_v1_avatar_clone_stream_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/tasks/{task_id}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Retry Avatar Task + * @description 重试失败或超时的形象克隆任务 + * + * 仅允许对 voice_failed / element_failed / timeout 状态的任务重试。 + * 重试时会重置状态为 pending 并重新派发 Celery 任务。 + */ + post: operations["retry_avatar_task_api_v1_avatar_tasks__task_id__retry_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/{avatar_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Avatar + * @description 删除形象:软删除 DB 记录 + 异步清理 Kling 资源 + */ + delete: operations["delete_avatar_api_v1_avatar__avatar_id__delete"]; + options?: never; + head?: never; + /** + * Update Avatar Name + * @description 更新形象名称 + * + * 同步更新本地和后端 DB,保证数据一致。 + */ + patch: operations["update_avatar_name_api_v1_avatar__avatar_id__patch"]; + trace?: never; + }; + "/api/v1/avatar/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Avatar Library + * @description 获取当前用户的克隆形象库 + * + * 返回 DB 中状态为 succeed 且未软删除的记录,供前端与 localStorage 合并。 + */ + get: operations["get_avatar_library_api_v1_avatar_library_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Avatar Health + * @description 获取形象克隆服务健康状态 + * + * 普通用户只能看到自己的任务统计,管理员可以看到全部。 + */ + get: operations["get_avatar_health_api_v1_avatar_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/avatar/admin/trigger-recovery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Trigger Recovery + * @description 手动触发卡住任务恢复(管理员接口) + * + * 立即执行一次 check_and_recover_stuck_tasks 任务。 + */ + post: operations["admin_trigger_recovery_api_v1_avatar_admin_trigger_recovery_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/system/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * System Health + * @description 系统健康检查(详细版) + */ + get: operations["system_health_api_v1_system_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/system/version": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * System Version + * @description 获取系统版本信息 + */ + get: operations["system_version_api_v1_system_version_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit Caption Task + * @description 提交字幕生成任务 + * + * 提交音频/视频文件URL,生成带时间轴的字幕。 + */ + post: operations["submit_caption_task_api_v1_caption_submit_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/query/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Query Caption Task + * @description 查询字幕任务结果 + * + * Args: + * task_id: 任务ID + * blocking: 是否阻塞等待结果 (默认True) + */ + get: operations["query_caption_task_api_v1_caption_query__task_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/generate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Caption + * @description 生成字幕(完整流程) + * + * 提交任务并轮询结果,直接返回最终字幕数据。 + * 适用于不需要异步处理的场景。 + */ + post: operations["generate_caption_api_v1_caption_generate_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/generate-ass": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Ass + * @description 生成 ASS 格式字幕(完整流程,使用抖音美好体) + * + * Args: + * video_width: 视频宽度(默认 1080) + * video_height: 视频高度(默认 1920) + */ + post: operations["generate_ass_api_v1_caption_generate_ass_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/generate-srt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Generate Srt + * @description 生成 SRT 格式字幕(完整流程) + * + * 直接返回 SRT 格式字幕文件内容。 + */ + post: operations["generate_srt_api_v1_caption_generate_srt_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/ata/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit Auto Align Task + * @description 提交自动字幕打轴任务 + * + * 为已有字幕文本自动配上时间轴。 + */ + post: operations["submit_auto_align_task_api_v1_caption_ata_submit_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/ata/query/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Query Auto Align Task + * @description 查询打轴任务结果 + */ + get: operations["query_auto_align_task_api_v1_caption_ata_query__task_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/ata/align": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Auto Align Caption + * @description 自动字幕打轴(完整流程) + * + * 提交打轴任务并轮询结果,直接返回最终数据。 + */ + post: operations["auto_align_caption_api_v1_caption_ata_align_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/convert/ass": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Convert To Ass + * @description 将字幕结果转换为 ASS 格式(使用抖音美好体) + */ + post: operations["convert_to_ass_api_v1_caption_convert_ass_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/convert/srt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Convert To Srt + * @description 将字幕结果转换为 SRT 格式 + * + * 用于将 /generate 返回的原始数据转换为 SRT 格式。 + */ + post: operations["convert_to_srt_api_v1_caption_convert_srt_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/caption/convert/vtt": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Convert To Vtt + * @description 将字幕结果转换为 WebVTT 格式 + */ + post: operations["convert_to_vtt_api_v1_caption_convert_vtt_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tasks/{task_type}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Task + * @description 创建新任务 + * + * 根据任务类型派发对应的 Celery 任务 + */ + post: operations["create_task_api_v1_tasks__task_type__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tasks/{task_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Task Status + * @description 查询任务状态 + * + * 前端通过轮询此接口获取任务进度 + */ + get: operations["get_task_status_api_v1_tasks__task_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tasks/{task_id}/result": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Task Result + * @description 获取任务结果(简化接口,直接返回 result 字段) + */ + get: operations["get_task_result_api_v1_tasks__task_id__result_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health Check + * @description 服务健康检查 + */ + get: operations["health_check_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Root + * @description API 根路径 + */ + get: operations["root__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * AdvancedLipSyncRequest + * @description 新版对口型请求(advanced-lip-sync) + */ + AdvancedLipSyncRequest: { + /** + * Session Id + * @description 人脸识别返回的会话ID + */ + session_id: string; + /** + * Face Choose + * @description 人脸对口型配置列表 + */ + face_choose: components["schemas"]["FaceChooseItem"][]; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * AiMultiShotRequest + * @description 智能补全主体图请求 + */ + AiMultiShotRequest: { + /** + * Frontal Image + * @description 主体正面参考图 URL + * @example https://example.com/front.jpg + */ + frontal_image: string; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * AiMultiShotResponse + * @description 智能补全主体图响应 + */ + AiMultiShotResponse: { + /** Task Id */ + task_id: string; + /** Task Status */ + task_status: string; + /** Created At */ + created_at: number; + /** Updated At */ + updated_at: number; + }; + /** + * ApiResponse[AiMultiShotResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_AiMultiShotResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["AiMultiShotResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[AutoAlignResult] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_AutoAlignResult_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["AutoAlignResult"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[AvatarHealthResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_AvatarHealthResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["AvatarHealthResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[AvatarTaskStatusResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_AvatarTaskStatusResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["AvatarTaskStatusResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[CaptionResult] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_CaptionResult_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["CaptionResult-Output"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[CaptionTaskResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_CaptionTaskResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["CaptionTaskResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[CloneAvatarResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_CloneAvatarResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["CloneAvatarResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[CreateCustomVoiceResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_CreateCustomVoiceResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["CreateCustomVoiceResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[DigitalHuman] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_DigitalHuman_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["DigitalHuman"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[ElementResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_ElementResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["ElementResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[FileUploadResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_FileUploadResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["FileUploadResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[GenerateResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_GenerateResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["GenerateResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[HealthResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_HealthResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["HealthResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[IdentifyFaceResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_IdentifyFaceResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["IdentifyFaceResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[LoginResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_LoginResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["LoginResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[ModelHealthResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_ModelHealthResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["ModelHealthResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[PolishResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_PolishResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["PolishResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[PromptTemplatesResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_PromptTemplatesResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["PromptTemplatesResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[ScriptGenerateResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_ScriptGenerateResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["ScriptGenerateResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[SrtSubtitleResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_SrtSubtitleResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["SrtSubtitleResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[TaskStatusResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_TaskStatusResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["app__api__v1__klingai__TaskStatusResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[TestModelResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_TestModelResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["TestModelResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[UploadTokenResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_UploadTokenResponse_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["UploadTokenResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[VideoJobDetail] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_VideoJobDetail_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["VideoJobDetail"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[VideoJobStatus] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_VideoJobStatus_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["VideoJobStatus"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[dict] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_dict_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: Record | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[AvatarItem]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_AvatarItem__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["AvatarItem"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[DigitalHuman]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_DigitalHuman__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["DigitalHuman"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[ElementResponse]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_ElementResponse__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["ElementResponse"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[ModelResponse]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_ModelResponse__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["ModelResponse"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[PlatformResponse]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_PlatformResponse__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["PlatformResponse"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[ScriptShot]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_ScriptShot__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["ScriptShot"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[list[VoiceInfo]] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_list_VoiceInfo__: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: components["schemas"]["VoiceInfo"][] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[str] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + ApiResponse_str_: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** + * Data + * @description 响应数据 + */ + data?: string | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * AutoAlignResult + * @description 自动字幕打轴结果 + */ + AutoAlignResult: { + /** + * Code + * @description 状态码: 0=成功, 2000=处理中 + */ + code: number; + /** + * Message + * @description 状态信息 + */ + message: string; + /** + * Duration + * @description 音频时长(秒) + */ + duration: number; + /** + * Utterances + * @description 打轴后的字幕时间轴 + */ + utterances?: components["schemas"]["CaptionUtterance"][] | null; + }; + /** + * AutoAlignSubmitRequest + * @description 自动字幕打轴任务提交请求 + */ + AutoAlignSubmitRequest: { + /** + * Audio Url + * @description 音频/视频文件URL + */ + audio_url: string; + /** + * Audio Text + * @description 要打轴的字幕文本 + */ + audio_text: string; + /** + * Caption Type + * @description 识别类型: speech(说话), singing(歌词) + * @default speech + */ + caption_type: string; + /** + * Sta Punc Mode + * @description 标点模式: 1=省略句末, 2=空格代替, 3=保留完整 + * @default 3 + */ + sta_punc_mode: number; + }; + /** + * AvatarHealthResponse + * @description 形象克隆服务健康状态 + */ + AvatarHealthResponse: { + /** + * Total Processing + * @description 处理中的任务总数 + */ + total_processing: number; + /** + * Pending + * @description 待处理任务数 + */ + pending: number; + /** + * Voice Processing + * @description 音色生成中任务数 + */ + voice_processing: number; + /** + * Element Processing + * @description 主体生成中任务数 + */ + element_processing: number; + /** + * Stuck Tasks + * @description 卡住任务数(超过30分钟) + */ + stuck_tasks: number; + /** + * Recent Failures + * @description 最近1小时失败数 + */ + recent_failures: number; + }; + /** + * AvatarItem + * @description 形象库列表项 + */ + AvatarItem: { + /** + * Id + * @description 形象唯一标识 + */ + id: string; + /** + * Name + * @description 展示名称 + */ + name: string; + /** + * Voice Id + * @description Kling 自定义音色 ID + */ + voice_id: string; + /** + * Element Id + * @description Kling 主体 ID + */ + element_id: number; + /** + * Video Url + * @description 原始人物视频 URL + */ + video_url: string; + /** + * Trial Url + * @description 音色试听 URL + */ + trial_url?: string | null; + /** + * Record Time + * @description 创建时间 ISO 字符串 + */ + record_time: string; + }; + /** + * AvatarTaskStatusResponse + * @description 任务状态查询响应 + */ + AvatarTaskStatusResponse: { + /** Task Id */ + task_id: string; + /** + * Status + * @description 当前状态 + */ + status: string; + /** + * Fail Reason + * @description 失败原因 + */ + fail_reason?: string | null; + /** + * Voice Id + * @description 已生成的音色 ID + */ + voice_id?: string | null; + /** + * Element Id + * @description 已生成的主体 ID + */ + element_id?: number | null; + /** + * Trial Url + * @description 试听 URL + */ + trial_url?: string | null; + /** + * Video Url + * @description 原始视频 URL + */ + video_url: string; + /** + * Name + * @description 形象名称 + */ + name: string; + /** + * Created At + * Format: date-time + * @description 创建时间 + */ + created_at: string; + /** + * Updated At + * Format: date-time + * @description 更新时间 + */ + updated_at: string; + }; + /** Body_upload_audio_api_v1_qiniu_upload_audio_post */ + Body_upload_audio_api_v1_qiniu_upload_audio_post: { + /** + * File + * @description 音频文件(MP3, WAV, M4A, AAC, OGG) + */ + file: string; + /** + * Userid + * @description 用户ID(可选,用于目录隔离) + */ + userId?: string | null; + }; + /** Body_upload_avatar_api_v1_qiniu_upload_avatar_post */ + Body_upload_avatar_api_v1_qiniu_upload_avatar_post: { + /** + * File + * @description 形象克隆视频(MP4, MOV) + */ + file: string; + /** + * Userid + * @description 用户ID(可选,用于目录隔离) + */ + userId?: string | null; + /** + * Filehash + * @description 前端计算的文件SHA256哈希,用于重复检测 + */ + fileHash?: string | null; + }; + /** Body_upload_video_api_v1_qiniu_upload_video_post */ + Body_upload_video_api_v1_qiniu_upload_video_post: { + /** + * File + * @description 视频文件(MP4, MOV, AVI, WebM) + */ + file: string; + /** + * Userid + * @description 用户ID(可选,用于目录隔离) + */ + userId?: string | null; + }; + /** Body_upload_video_api_v1_video_upload_post */ + Body_upload_video_api_v1_video_upload_post: { + /** + * File + * @description 视频文件 + */ + file: string; + /** + * Name + * @description 数字人名称 + */ + name?: string | null; + }; + /** + * CaptionResult + * @description 字幕生成结果 + */ + "CaptionResult-Input": { + /** + * Code + * @description 状态码: 0=成功, 2000=处理中 + */ + code: number; + /** + * Message + * @description 状态信息 + */ + message: string; + /** + * Duration + * @description 音频时长(秒) + */ + duration: number; + /** + * Utterances + * @description 字幕时间轴列表 + */ + utterances?: components["schemas"]["CaptionUtterance"][] | null; + }; + /** + * CaptionResult + * @description 字幕生成结果 + */ + "CaptionResult-Output": { + /** + * Code + * @description 状态码: 0=成功, 2000=处理中 + */ + code: number; + /** + * Message + * @description 状态信息 + */ + message: string; + /** + * Duration + * @description 音频时长(秒) + */ + duration: number; + /** + * Utterances + * @description 字幕时间轴列表 + */ + utterances?: components["schemas"]["CaptionUtterance"][] | null; + }; + /** + * CaptionSubmitRequest + * @description 字幕生成任务提交请求 + */ + CaptionSubmitRequest: { + /** + * Audio Url + * @description 音频/视频文件URL + */ + audio_url: string; + /** + * Language + * @description 语言: zh-CN, en-US, ja-JP, ko-KR, es-MX, ru-RU, fr-FR, yue, wuu, nan, ug + * @default zh-CN + */ + language: string; + /** + * Caption Type + * @description 识别类型: auto(自动), speech(说话), singing(歌词) + * @default auto + */ + caption_type: string; + /** + * Use Punc + * @description 自动标点: True/False + * @default true + */ + use_punc: boolean; + /** + * Use Itn + * @description 数字转换: True(中文数字转阿拉伯数字) + * @default true + */ + use_itn: boolean; + /** + * Words Per Line + * @description 每行字数 + * @default 46 + */ + words_per_line: number; + /** + * Max Lines + * @description 每屏行数 + * @default 1 + */ + max_lines: number; + }; + /** + * CaptionTaskResponse + * @description 字幕任务提交响应 + */ + CaptionTaskResponse: { + /** + * Task Id + * @description 任务ID + */ + task_id: string; + /** + * Status + * @description 任务状态: pending/processing/completed/failed + */ + status: string; + }; + /** + * CaptionUtterance + * @description 一句话/一段字幕的时间轴信息 + */ + CaptionUtterance: { + /** + * Text + * @description 文本内容 + */ + text: string; + /** + * Start Time + * @description 开始时间(毫秒) + */ + start_time: number; + /** + * End Time + * @description 结束时间(毫秒) + */ + end_time: number; + /** + * Words + * @description 字词级时间轴 + */ + words?: components["schemas"]["CaptionWord"][] | null; + }; + /** + * CaptionWord + * @description 单个字/词的时间轴信息 + */ + CaptionWord: { + /** + * Text + * @description 字/词内容 + */ + text: string; + /** + * Start Time + * @description 开始时间(毫秒) + */ + start_time: number; + /** + * End Time + * @description 结束时间(毫秒) + */ + end_time: number; + }; + /** + * CloneAvatarRequest + * @description 创建形象克隆请求 + */ + CloneAvatarRequest: { + /** + * Name + * @description 形象名称 + */ + name: string; + /** + * Video Url + * @description 人物视频 URL + */ + video_url: string; + }; + /** + * CloneAvatarResponse + * @description 创建形象克隆响应 + */ + CloneAvatarResponse: { + /** + * Task Id + * @description 任务 ID(用于 SSE/轮询跟踪进度) + */ + task_id: string; + /** + * Status + * @description 初始状态 + * @default pending + */ + status: string; + }; + /** + * CreateCustomVoiceRequest + * @description 创建自定义音色请求 + */ + CreateCustomVoiceRequest: { + /** + * Voice Name + * @description 音色名称(最多20字符) + * @example 我的音色 + */ + voice_name: string; + /** + * Audio Url + * @description 音频文件URL(mp3/wav/mp4/mov) + */ + audio_url?: string | null; + /** + * Video Url + * @description 视频文件URL + */ + video_url?: string | null; + /** + * Video Id + * @description 历史作品ID(v2.6/sound=on/数字人/对口型生成的视频) + */ + video_id?: string | null; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + /** + * External Task Id + * @description 自定义任务ID + */ + external_task_id?: string | null; + }; + /** + * CreateCustomVoiceResponse + * @description 创建自定义音色响应 + */ + CreateCustomVoiceResponse: { + /** Task Id */ + task_id: string; + /** Task Status */ + task_status: string; + /** Created At */ + created_at: number; + /** Updated At */ + updated_at: number; + }; + /** + * CreateElementRequest + * @description 创建主体请求 + */ + CreateElementRequest: { + /** + * Element Name + * @description 主体名称(最多20字符) + * @example 我的小猫 + */ + element_name: string; + /** + * Element Description + * @description 主体描述(最多100字符) + * @example 一只橘色的小猫,毛茸茸的 + */ + element_description: string; + /** + * Reference Type + * @description 参考类型: image_refer 或 video_refer + * @default image_refer + */ + reference_type: string; + /** + * Element Image List + * @description 图片参考列表(图片定制时必填,第一个作为正面图) + */ + element_image_list?: components["schemas"]["ElementImage"][] | null; + /** + * Element Video List + * @description 视频参考列表(视频定制时必填,第一个作为正面视频) + */ + element_video_list?: components["schemas"]["ElementVideo"][] | null; + /** + * Element Voice Id + * @description 音色ID,绑定音色到主体 + */ + element_voice_id?: string | null; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * DigitalHuman + * @description 数字人信息 + */ + DigitalHuman: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Desc */ + desc: string; + /** Avatar Url */ + avatar_url?: string | null; + /** + * Type + * @default preset + */ + type: string; + }; + /** + * ElementImage + * @description 主体参考图片(对应 KlingAI 官方格式 imageUrl) + */ + ElementImage: { + /** + * Image Url + * @description 图片URL + */ + image_url: string; + /** + * Name + * @description 图片名称 + */ + name?: string | null; + }; + /** + * ElementResponse + * @description 主体响应 + */ + ElementResponse: { + /** Element Id */ + element_id?: number | null; + /** Element Name */ + element_name?: string | null; + /** Element Description */ + element_description?: string | null; + /** Element Type */ + element_type?: string | null; + /** Status */ + status?: string | null; + /** Task Id */ + task_id?: string | null; + /** Task Status */ + task_status?: string | null; + /** Created At */ + created_at?: number | null; + /** Updated At */ + updated_at?: number | null; + /** Element Image List */ + element_image_list?: Record | null; + /** Element Video List */ + element_video_list?: Record | null; + /** Element Voice Info */ + element_voice_info?: Record | null; + /** Owned By */ + owned_by?: string | null; + }; + /** + * ElementVideo + * @description 主体参考视频(对应 KlingAI 官方格式 videoUrl) + */ + ElementVideo: { + /** + * Video Url + * @description 视频URL + */ + video_url: string; + /** + * Name + * @description 视频名称 + */ + name?: string | null; + }; + /** + * FaceChooseItem + * @description 新版对口型人脸配置 + */ + FaceChooseItem: { + /** + * Face Id + * @description 人脸ID,由 identify-face 接口返回 + */ + face_id: string; + /** + * Audio Id + * @description 通过TTS生成的音频ID(与 sound_file 二选一) + */ + audio_id?: string | null; + /** + * Sound File + * @description 音频文件URL或Base64(与 audio_id 二选一) + */ + sound_file?: string | null; + /** + * Sound Start Time + * @description 音频裁剪起点时间(ms) + * @default 0 + */ + sound_start_time: number; + /** + * Sound End Time + * @description 音频裁剪终点时间(ms) + */ + sound_end_time: number; + /** + * Sound Insert Time + * @description 裁剪后音频插入时间(ms) + * @default 0 + */ + sound_insert_time: number; + }; + /** + * FileUploadResponse + * @description 文件上传响应 + */ + FileUploadResponse: { + /** Key */ + key: string; + /** Url */ + url: string; + /** Hash */ + hash: string; + /** Mimetype */ + mimeType: string; + /** Fsize */ + fsize: number; + /** + * Isduplicate + * @default false + */ + isDuplicate: boolean; + /** Message */ + message?: string | null; + /** Existingtaskid */ + existingTaskId?: string | null; + }; + /** + * GenerateRequest + * @description 生成请求 + */ + GenerateRequest: { + /** + * Prompt + * @description 提示词 + */ + prompt: string; + /** + * Model Id + * @description 指定模型 ID + */ + model_id?: string | null; + /** + * Task Type + * @description 任务类型,用于自动选模型: script/polish + */ + task_type?: string | null; + /** + * Temperature + * @description 随机性 (0-2) + */ + temperature?: number | null; + /** + * Max Tokens + * @description 最大生成长度 + */ + max_tokens?: number | null; + }; + /** + * GenerateResponse + * @description 生成响应 + */ + GenerateResponse: { + /** Content */ + content: string; + /** Model */ + model: string; + /** Usage */ + usage: Record | null; + }; + /** + * GenerateScriptRequest + * @description 生成脚本请求 + */ + GenerateScriptRequest: { + /** + * Topic + * @description 创作主题/灵感 + */ + topic: string; + /** + * Duration + * @description 视频时长(秒) + * @default 45 + */ + duration: number; + /** + * Script Type + * @description 脚本类型 + * @default 干货型 + */ + script_type: string; + /** + * Model + * @description 指定模型(可选) + */ + model?: string | null; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * HealthResponse + * @description 健康检查响应 + */ + HealthResponse: { + /** Status */ + status: string; + /** Total Models */ + total_models: number; + /** Available Models */ + available_models: number; + /** Models */ + models: Record[]; + }; + /** + * IdentifyFaceRequest + * @description 人脸识别请求 + */ + IdentifyFaceRequest: { + /** + * Video Id + * @description KlingAI 生成的视频 ID + */ + video_id?: string | null; + /** + * Video Url + * @description 上传的视频 URL(与 videoId 二选一 + */ + video_url?: string | null; + }; + /** + * IdentifyFaceResponse + * @description 人脸识别响应 + */ + IdentifyFaceResponse: { + /** Session Id */ + session_id: string; + /** Face Data */ + face_data: Record[]; + }; + /** + * Image2VideoRequest + * @description 图生视频请求 + */ + Image2VideoRequest: { + /** + * Image Url + * @description 输入图片 URL + */ + image_url: string; + /** + * Prompt + * @description 视频运动描述提示词 + */ + prompt?: string | null; + /** + * Model + * @description 视频模型 + * @default kling-v2.6 + */ + model: string | null; + /** + * Duration + * @description 视频时长(秒) + * @default 5 + */ + duration: number; + /** + * Aspect Ratio + * @description 宽高比 + */ + aspect_ratio?: string | null; + /** + * Mode + * @description 生成模式 + * @default pro + */ + mode: string; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * ImageGenerateRequest + * @description 图像生成请求 + */ + ImageGenerateRequest: { + /** + * Prompt + * @description 图像描述提示词 + */ + prompt: string; + /** + * Model + * @description 图像模型: kolors-v1 + * @default kolors-v1 + */ + model: string | null; + /** + * Width + * @description 图像宽度 + * @default 1024 + */ + width: number; + /** + * Height + * @description 图像高度 + * @default 1024 + */ + height: number; + /** + * Negative Prompt + * @description 负面提示词 + */ + negative_prompt?: string | null; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * LoginResponse + * @description 登录响应 + */ + LoginResponse: { + /** + * Token + * @description JWT 访问令牌 + */ + token: string; + user: components["schemas"]["UserInfo"]; + }; + /** + * MobileLoginRequest + * @description 手机号登录请求 + */ + MobileLoginRequest: { + /** + * Mobile + * @description 手机号 + */ + mobile: string; + /** + * Nickname + * @description 用户昵称 + */ + nickname?: string | null; + }; + /** + * ModelHealthInfo + * @description 模型健康信息 + */ + ModelHealthInfo: { + /** + * Id + * @description 模型 ID + */ + id: string; + /** + * Name + * @description 模型名称 + */ + name: string; + /** + * Is Available + * @description 是否可用 + */ + is_available: boolean; + /** + * Response Time + * @description 响应时间(毫秒) + */ + response_time: number; + /** + * Last Error + * @description 上次错误信息 + */ + last_error?: string | null; + }; + /** + * ModelHealthResponse + * @description 模型健康检查响应 + */ + ModelHealthResponse: { + /** + * Status + * @description 整体状态:healthy / unhealthy / error + */ + status: string; + /** + * Models + * @description 各模型状态 + */ + models: components["schemas"]["ModelHealthInfo"][]; + /** @description 推荐的模型 */ + recommended_model?: components["schemas"]["ModelHealthInfo"] | null; + /** + * Total Models + * @description 模型总数 + */ + total_models: number; + /** + * Available Models + * @description 可用模型数 + */ + available_models: number; + /** + * Error + * @description 错误信息 + */ + error?: string | null; + }; + /** + * ModelResponse + * @description 模型响应 + */ + ModelResponse: { + /** Id */ + id: string; + /** Platform Id */ + platform_id: string; + /** Model Name */ + model_name: string; + /** Display Name */ + display_name: string; + /** Capabilities */ + capabilities: string[]; + /** Default Params */ + default_params: Record; + /** Is Enabled */ + is_enabled: boolean; + /** Full Model Id */ + full_model_id: string; + }; + /** + * OmniImageRequest + * @description Omni-Image 图像生成请求 + */ + OmniImageRequest: { + /** + * Prompt + * @description 图像描述提示词 + */ + prompt: string; + /** + * Model + * @description 模型: kling-image-o1, kling-v3-omni + * @default kling-image-o1 + */ + model: string | null; + /** + * Aspect Ratio + * @description 宽高比: 16:9/9:16/1:1/4:3/3:4 + * @default 9:16 + */ + aspect_ratio: string | null; + /** + * Resolution + * @description 清晰度: 1k/2k/4k + * @default 1k + */ + resolution: string | null; + /** + * Result Type + * @description 结果类型: single/series + * @default single + */ + result_type: string | null; + /** + * N + * @description 生成数量 1-9 + * @default 1 + */ + n: number | null; + /** + * Element List + * @description 主体参考列表 + */ + element_list?: Record[] | null; + /** + * Image List + * @description 参考图列表 + */ + image_list?: Record[] | null; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * OmniVideoRequest + * @description Omni-Video 视频生成请求(kling-v3-omni / kling-video-o1) + */ + OmniVideoRequest: { + /** + * Prompt + * @description 视频描述提示词(不超过2500字符),支持 <<>>/<<>>/<<>> 引用语法 + * @example 一只<<>>在花园里奔跑,阳光明媚 + */ + prompt: string; + /** + * Model + * @description 模型: kling-v3-omni, kling-video-o1 + * @default kling-v3-omni + */ + model: string | null; + /** + * Duration + * @description 视频时长(秒): 3/5/10/15,Omni支持3-15秒 + * @default 5 + */ + duration: number; + /** + * Aspect Ratio + * @description 宽高比: 16:9, 9:16, 1:1 + * @default 16:9 + */ + aspect_ratio: string; + /** + * Mode + * @description 生成模式: pro(高质量) 或 std(标准) + * @default pro + */ + mode: string; + /** + * Sound + * @description 声音控制: on=音画同出, off=无声 + * @default on + */ + sound: string; + /** + * Negative Prompt + * @description 负面提示词 + */ + negative_prompt?: string | null; + /** + * Multi Shot + * @description 是否启用多镜头模式 + * @default false + */ + multi_shot: boolean; + /** + * Shot Type + * @description 分镜方式: customize=自定义, intelligence=智能分镜 + */ + shot_type?: string | null; + /** + * Multi Prompt + * @description 多镜头提示词列表,每个元素包含 index, prompt, duration,最多6个分镜 + */ + multi_prompt?: (Record | null)[]; + /** + * Image List + * @description 参考图片列表,最多4张 + */ + image_list?: (Record | null)[]; + /** + * Element List + * @description 主体参考列表,格式: [{'elementId': 123}], 最多7个 + */ + element_list?: (Record | null)[]; + /** + * Video List + * @description 参考视频列表 + */ + video_list?: (Record | null)[]; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + /** + * External Task Id + * @description 自定义任务ID + */ + external_task_id?: string | null; + }; + /** + * PlatformResponse + * @description 平台响应 + */ + PlatformResponse: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Provider */ + provider: string; + }; + /** + * PolishResponse + * @description 润色响应 + */ + PolishResponse: { + /** Success */ + success: boolean; + /** Original */ + original: string; + /** Polished */ + polished: string | null; + /** Polish Type */ + polish_type: string; + /** Model */ + model: string; + /** Usage */ + usage: Record | null; + }; + /** + * PromptTemplatesResponse + * @description Prompt 模板配置响应 + */ + PromptTemplatesResponse: { + /** Script Types */ + script_types: Record[]; + /** Video Styles */ + video_styles: Record[]; + /** Tones */ + tones: string[]; + }; + /** + * ScriptGenerateRequest + * @description 脚本生成请求 + */ + ScriptGenerateRequest: { + /** + * Topic + * @description 脚本主题 + * @example 水电改造的3个致命错误 + */ + topic: string; + /** + * Duration + * @description 视频时长(秒) + * @default 30 + */ + duration: number; + /** + * Script Type + * @description 脚本类型 + * @default 干货型 + */ + script_type: string; + /** + * Video Style + * @description 视频风格 + * @default 口播 + */ + video_style: string; + /** + * Tone + * @description 语气风格 + */ + tone?: string | null; + /** + * Requirements + * @description 额外要求 + */ + requirements?: string | null; + /** + * Model Id + * @description 指定模型ID,默认使用系统默认模型 + */ + model_id?: string | null; + }; + /** + * ScriptGenerateResponse + * @description 脚本生成响应 - 针对前端展示优化 + */ + ScriptGenerateResponse: { + /** Success */ + success: boolean; + /** Script */ + script: (Record | null)[]; + /** Total Duration */ + total_duration: number | null; + /** Target Duration */ + target_duration: number; + /** Total Word Count */ + total_word_count: number | null; + /** Segment Count */ + segment_count: number | null; + /** Empty Shot Count */ + empty_shot_count: number | null; + /** Script Type */ + script_type: string; + /** Model */ + model: string; + /** Usage */ + usage: Record | null; + /** Error */ + error: string | null; + /** Raw Content */ + raw_content: string | null; + }; + /** + * ScriptShot + * @description 分镜/镜头定义 + */ + ScriptShot: { + /** + * Id + * @description 分镜序号 + */ + id: number; + /** + * Type + * @description 类型:segment(分镜) / empty_shot(空镜) + * @default segment + */ + type: string; + /** + * Scene + * @description 画面描述 + */ + scene?: string | null; + /** + * Voiceover + * @description 配音文案(空镜为空字符串) + */ + voiceover: string; + /** + * Duration + * @description 时长(如:5s) + * @default 5s + */ + duration: string; + /** + * Word Count + * @description 字数统计 + */ + word_count?: number | null; + }; + /** + * ShotData + * @description 分镜数据 + */ + ShotData: { + /** + * Id + * @description 分镜ID + */ + id: string; + /** + * Type + * @description 分镜类型: segment(分镜) 或 empty_shot(空镜) + * @default segment + */ + type: string; + /** + * Scene + * @description 场景描述 + * @default + */ + scene: string; + /** + * Voiceover + * @description 配音文案 + * @default + */ + voiceover: string; + /** + * Voice Id + * @description 音色ID(空镜时使用) + */ + voice_id?: string | null; + }; + /** + * ShotResult + * @description 单个分镜结果 + */ + ShotResult: { + /** Shot Id */ + shot_id: string; + /** Shot Type */ + shot_type: string; + /** Status */ + status: string; + /** Task Id */ + task_id?: string | null; + /** Video Url */ + video_url?: string | null; + /** Local Path */ + local_path?: string | null; + /** Error Message */ + error_message?: string | null; + }; + /** + * SrtSubtitleResponse + * @description SRT 字幕格式响应 + */ + SrtSubtitleResponse: { + /** + * Srt Content + * @description SRT 格式字幕内容 + */ + srt_content: string; + /** + * Utterances + * @description 原始时间轴数据 + */ + utterances: components["schemas"]["CaptionUtterance"][]; + }; + /** + * TaskCreateRequest + * @description 创建任务请求 + */ + TaskCreateRequest: { + /** + * Project Id + * @description 项目ID(可选) + */ + project_id?: string | null; + /** + * Params + * @description 任务参数 + */ + params?: Record; + }; + /** + * TaskCreateResponse + * @description 创建任务响应 + */ + TaskCreateResponse: { + /** + * Task Id + * @description 任务ID + */ + task_id: string; + /** + * Status + * @description 任务状态 + * @default pending + */ + status: string; + /** + * Message + * @description 状态消息 + * @default 任务已创建 + */ + message: string; + }; + /** + * TestModelRequest + * @description 测试模型请求 + */ + TestModelRequest: { + /** + * Model Id + * @description 要测试的模型 ID + */ + model_id?: string | null; + }; + /** + * TestModelResponse + * @description 测试模型响应 + */ + TestModelResponse: { + /** + * Success + * @description 是否成功 + */ + success: boolean; + /** + * Model + * @description 模型名称 + */ + model: string; + /** + * Response Time + * @description 响应时间(毫秒) + */ + response_time?: number | null; + /** + * Error + * @description 错误信息 + */ + error?: string | null; + /** + * Checked At + * @description 检查时间 ISO 格式 + */ + checked_at?: string | null; + }; + /** + * UpdateAvatarNameRequest + * @description 更新形象名称请求 + */ + UpdateAvatarNameRequest: { + /** + * Name + * @description 新形象名称 + */ + name: string; + }; + /** + * UploadTokenRequest + * @description 上传凭证请求 + */ + UploadTokenRequest: { + /** + * Key + * @description 文件存储 Key + */ + key: string; + /** + * Expires + * @description Token 有效期(秒) + * @default 3600 + */ + expires: number; + }; + /** + * UploadTokenResponse + * @description 上传凭证响应 + */ + UploadTokenResponse: { + /** Token */ + token: string; + /** Key */ + key: string; + /** + * Uploadurl + * @default https://upload.qiniup.com + */ + uploadUrl: string; + }; + /** + * UserInfo + * @description 用户信息 + */ + UserInfo: { + /** + * Id + * @description 用户 ID + */ + id: string; + /** + * Nickname + * @description 用户昵称 + */ + nickname: string; + /** + * Avatar + * @description 头像 URL + * @default + */ + avatar: string; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** + * VideoGenerateRequest + * @description 视频生成请求 + */ + VideoGenerateRequest: { + /** + * Project Id + * @description 项目ID + */ + project_id: string; + /** + * Element Id + * @description Kling主体ID(数字类型,分镜类型使用) + */ + element_id?: number | null; + /** + * Shots + * @description 分镜列表 + */ + shots: components["schemas"]["ShotData"][]; + }; + /** + * VideoJobDetail + * @description 视频作业详情 + */ + VideoJobDetail: { + /** Job Id */ + job_id: string; + /** Project Id */ + project_id: string; + /** Status */ + status: string; + /** Progress */ + progress: number; + /** Total Shots */ + total_shots: number; + /** Completed Shots */ + completed_shots: number; + /** Failed Shots */ + failed_shots: number; + /** Shots */ + shots: components["schemas"]["ShotResult"][]; + /** Created At */ + created_at: number; + /** Updated At */ + updated_at: number; + }; + /** + * VideoJobStatus + * @description 视频作业状态 + */ + VideoJobStatus: { + /** Job Id */ + job_id: string; + /** Project Id */ + project_id: string; + /** Status */ + status: string; + /** Progress */ + progress: number; + /** Total Shots */ + total_shots: number; + /** Completed Shots */ + completed_shots: number; + /** Failed Shots */ + failed_shots: number; + /** Created At */ + created_at: number; + /** Updated At */ + updated_at: number; + /** Error Message */ + error_message?: string | null; + }; + /** + * VirtualTryonRequest + * @description 虚拟试穿请求 + */ + VirtualTryonRequest: { + /** + * Person Image Url + * @description 人物图片 URL + */ + person_image_url: string; + /** + * Cloth Image Url + * @description 衣服图片 URL + */ + cloth_image_url: string; + /** + * Callback Url + * @description 回调通知地址 + */ + callback_url?: string | null; + }; + /** + * VoiceInfo + * @description 音色信息 + */ + VoiceInfo: { + /** Voice Id */ + voice_id: string; + /** Voice Name */ + voice_name: string; + /** Trial Url */ + trial_url?: string | null; + /** Owned By */ + owned_by?: string | null; + /** Status */ + status?: string | null; + }; + /** + * PolishRequest + * @description 润色请求 + */ + app__api__v1__ai_models__PolishRequest: { + /** + * Content + * @description 需要润色的内容 + */ + content: string; + /** + * Polish Type + * @description 润色类型:scene/voiceover + * @default voiceover + */ + polish_type: string; + /** + * Model Id + * @description 指定模型ID + */ + model_id?: string | null; + }; + /** + * TaskStatusResponse + * @description 任务状态响应 + */ + app__api__v1__klingai__TaskStatusResponse: { + /** Task Id */ + task_id: string; + /** Task Status */ + task_status: string; + /** Created At */ + created_at: number; + /** Updated At */ + updated_at: number; + /** Video Url */ + video_url?: string | null; + /** Image Url */ + image_url?: string | null; + /** Error Message */ + error_message?: string | null; + }; + /** + * VideoGenerateResponse + * @description 视频生成响应 + */ + app__api__v1__klingai__VideoGenerateResponse: { + /** Task Id */ + task_id: string; + /** Task Status */ + task_status: string; + /** Created At */ + created_at: number; + /** Updated At */ + updated_at: number; + }; + /** + * TaskStatusResponse + * @description 任务状态响应 + */ + app__api__v1__tasks__TaskStatusResponse: { + /** + * Task Id + * @description 任务ID + */ + task_id: string; + /** + * Type + * @description 任务类型 + */ + type?: string | null; + /** + * Status + * @description 任务状态: pending/running/waiting/completed/failed + */ + status: string; + /** + * Progress + * @description 进度百分比 (0-100) + * @default 0 + */ + progress: number; + /** + * Message + * @description 状态描述 + * @default + */ + message: string; + /** + * Result + * @description 任务结果(完成时) + */ + result?: Record | null; + /** + * Error + * @description 错误信息(失败时) + */ + error?: string | null; + }; + /** + * VideoGenerateResponse + * @description 视频生成响应 + */ + app__api__v1__video__VideoGenerateResponse: { + /** + * Job Id + * @description 作业ID + */ + job_id: string; + /** + * Task Id + * @description 任务ID(与job_id相同,兼容前端) + */ + task_id: string; + /** + * Status + * @description 作业状态 + */ + status: string; + /** + * Message + * @description 状态消息 + */ + message: string; + /** + * Sse Url + * @description SSE进度流URL + */ + sse_url: string; + }; + /** + * ApiResponse[VideoGenerateResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + app__schemas__common__ApiResponse_VideoGenerateResponse___1: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["app__api__v1__klingai__VideoGenerateResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * ApiResponse[VideoGenerateResponse] + * @example { + * "code": 200, + * "data": {}, + * "message": "success" + * } + */ + app__schemas__common__ApiResponse_VideoGenerateResponse___2: { + /** + * Code + * @description 状态码,200 表示成功 + * @default 200 + */ + code: number; + /** @description 响应数据 */ + data?: components["schemas"]["app__api__v1__video__VideoGenerateResponse"] | null; + /** + * Message + * @description 提示信息 + * @default success + */ + message: string; + }; + /** + * PolishRequest + * @description 润色请求 + */ + app__schemas__script__PolishRequest: { + /** + * Content + * @description 待润色内容 + */ + content: string; + /** + * Polish Type + * @description 润色类型:scene / voiceover + * @default voiceover + */ + polish_type: string; + /** + * Shot Type + * @description 镜头类型:segment(分镜) / empty_shot(空镜),用于画面润色时区分 + * @default segment + */ + shot_type: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + login_api_v1_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MobileLoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_LoginResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_me_api_v1_auth_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + }; + }; + refresh_token_api_v1_auth_refresh_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + }; + }; + generate_script_api_v1_script_generate_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateScriptRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_ScriptShot__"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_script_stream_api_v1_script_generate_stream_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateScriptRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + polish_content_api_v1_script_polish_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["app__schemas__script__PolishRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_str_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + check_model_health_api_v1_script_model_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_ModelHealthResponse_"]; + }; + }; + }; + }; + test_model_api_v1_script_test_model_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TestModelRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_TestModelResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_platforms_api_v1_ai_platforms_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_PlatformResponse__"]; + }; + }; + }; + }; + list_models_api_v1_ai_models_get: { + parameters: { + query?: { + capability?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_ModelResponse__"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_text_api_v1_ai_generate_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GenerateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_GenerateResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health_check_api_v1_ai_health_get: { + parameters: { + query?: { + model_id?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_HealthResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + test_platform_connection_api_v1_ai_platforms__platform_id__test_get: { + parameters: { + query?: never; + header?: never; + path: { + platform_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reload_config_api_v1_ai_reload_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + }; + }; + get_prompt_templates_api_v1_ai_prompts_templates_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_PromptTemplatesResponse_"]; + }; + }; + }; + }; + build_system_prompt_api_v1_ai_prompts_build_post: { + parameters: { + query?: { + duration?: number; + script_type?: string; + video_style?: string; + tone?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_script_api_v1_ai_scripts_generate_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ScriptGenerateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_ScriptGenerateResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + polish_script_content_api_v1_ai_scripts_polish_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["app__api__v1__ai_models__PolishRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_PolishResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_omni_video_tasks_api_v1_klingai_videos_omni_get: { + parameters: { + query?: { + page?: number; + page_size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_omni_video_api_v1_klingai_videos_omni_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OmniVideoRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_omni_video_task_api_v1_klingai_videos_omni__task_id__get: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_TaskStatusResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_image_to_video_api_v1_klingai_videos_image2video_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Image2VideoRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + extend_video_api_v1_klingai_videos_extend_post: { + parameters: { + query: { + video_id: string; + prompt?: string | null; + duration?: number; + callback_url?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + identify_face_api_v1_klingai_videos_identify_face_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IdentifyFaceRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_IdentifyFaceResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_advanced_lip_sync_api_v1_klingai_videos_advanced_lip_sync_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdvancedLipSyncRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_advanced_lip_sync_task_api_v1_klingai_videos_advanced_lip_sync__taskId__get: { + parameters: { + query?: never; + header?: never; + path: { + taskId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_TaskStatusResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_omni_image_api_v1_klingai_images_omni_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OmniImageRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_omni_image_task_api_v1_klingai_images_omni__task_id__get: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_TaskStatusResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_image_api_v1_klingai_images_generations_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ImageGenerateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_virtual_tryon_api_v1_klingai_virtual_tryon_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VirtualTryonRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___1"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_taskStatus_api_v1_klingai_tasks__taskId__get: { + parameters: { + query?: { + task_type?: string; + }; + header?: never; + path: { + taskId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_TaskStatusResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_tasks_api_v1_klingai_tasks_get: { + parameters: { + query?: { + task_type?: string; + page?: number; + page_size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_elements_api_v1_klingai_elements_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_ElementResponse__"]; + }; + }; + }; + }; + create_element_api_v1_klingai_elements_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateElementRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_ElementResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_element_api_v1_klingai_elements__elementId__get: { + parameters: { + query?: never; + header?: never; + path: { + elementId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_ElementResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_element_api_v1_klingai_elements__elementId__delete: { + parameters: { + query?: never; + header?: never; + path: { + elementId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + ai_multiShot_api_v1_klingai_elements_ai_multi_shot_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AiMultiShotRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_AiMultiShotResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_ai_multiShot_task_api_v1_klingai_elements_ai_multi_shot__taskId__get: { + parameters: { + query?: never; + header?: never; + path: { + taskId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_custom_voices_api_v1_klingai_voices_custom_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_VoiceInfo__"]; + }; + }; + }; + }; + create_custom_voice_api_v1_klingai_voices_custom_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateCustomVoiceRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_CreateCustomVoiceResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_custom_voice_api_v1_klingai_voices_custom__voiceId__get: { + parameters: { + query?: never; + header?: never; + path: { + voiceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_custom_voice_api_v1_klingai_voices_custom__voiceId__delete: { + parameters: { + query?: never; + header?: never; + path: { + voiceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_preset_voices_api_v1_klingai_voices_presets_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_VoiceInfo__"]; + }; + }; + }; + }; + get_upload_token_api_v1_qiniu_upload_token_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadTokenRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_UploadTokenResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_audio_api_v1_qiniu_upload_audio_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_audio_api_v1_qiniu_upload_audio_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_FileUploadResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_video_api_v1_qiniu_upload_video_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_video_api_v1_qiniu_upload_video_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_FileUploadResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_avatar_api_v1_qiniu_upload_avatar_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_avatar_api_v1_qiniu_upload_avatar_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_FileUploadResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_file_info_api_v1_qiniu_files__key__get: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_file_api_v1_qiniu_files__key__delete: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + refresh_cdn_api_v1_qiniu_refresh_cdn_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": string[]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_video_generation_api_v1_video_generate_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VideoGenerateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__schemas__common__ApiResponse_VideoGenerateResponse___2"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_video_job_api_v1_video_jobs__job_id__get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_VideoJobDetail_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + stream_video_job_api_v1_video_jobs__job_id__stream_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_video_job_status_api_v1_video_jobs__job_id__status_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_VideoJobStatus_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_digital_humans_api_v1_video_library_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_DigitalHuman__"]; + }; + }; + }; + }; + upload_video_api_v1_video_upload_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_video_api_v1_video_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_DigitalHuman_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + download_video_api_v1_video__video_id__download_get: { + parameters: { + query?: never; + header?: never; + path: { + video_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_video_thumbnail_api_v1_video__video_id__thumbnail_get: { + parameters: { + query?: never; + header?: never; + path: { + video_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + clone_avatar_api_v1_avatar_clone_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloneAvatarRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_CloneAvatarResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_avatar_task_status_api_v1_avatar_tasks__task_id__get: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_AvatarTaskStatusResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + sse_avatar_clone_api_v1_avatar_clone_stream_get: { + parameters: { + query: { + /** @description 任务 ID */ + task_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + retry_avatar_task_api_v1_avatar_tasks__task_id__retry_post: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_avatar_api_v1_avatar__avatar_id__delete: { + parameters: { + query?: { + voice_id?: string | null; + }; + header?: never; + path: { + avatar_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_avatar_name_api_v1_avatar__avatar_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + avatar_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateAvatarNameRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_avatar_library_api_v1_avatar_library_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_list_AvatarItem__"]; + }; + }; + }; + }; + get_avatar_health_api_v1_avatar_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_AvatarHealthResponse_"]; + }; + }; + }; + }; + admin_trigger_recovery_api_v1_avatar_admin_trigger_recovery_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + }; + }; + system_health_api_v1_system_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + }; + }; + system_version_api_v1_system_version_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + }; + }; + submit_caption_task_api_v1_caption_submit_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionSubmitRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_CaptionTaskResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + query_caption_task_api_v1_caption_query__task_id__get: { + parameters: { + query?: { + blocking?: boolean; + }; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_CaptionResult_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_caption_api_v1_caption_generate_post: { + parameters: { + query?: { + max_wait_time?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionSubmitRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_CaptionResult_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_ass_api_v1_caption_generate_ass_post: { + parameters: { + query?: { + video_width?: number; + video_height?: number; + max_wait_time?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionSubmitRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + generate_srt_api_v1_caption_generate_srt_post: { + parameters: { + query?: { + max_wait_time?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionSubmitRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_SrtSubtitleResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + submit_auto_align_task_api_v1_caption_ata_submit_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AutoAlignSubmitRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_CaptionTaskResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + query_auto_align_task_api_v1_caption_ata_query__task_id__get: { + parameters: { + query?: { + blocking?: boolean; + }; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_AutoAlignResult_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + auto_align_caption_api_v1_caption_ata_align_post: { + parameters: { + query?: { + max_wait_time?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AutoAlignSubmitRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + convert_to_ass_api_v1_caption_convert_ass_post: { + parameters: { + query?: { + video_width?: number; + video_height?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionResult-Input"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + convert_to_srt_api_v1_caption_convert_srt_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionResult-Input"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + convert_to_vtt_api_v1_caption_convert_vtt_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CaptionResult-Input"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse_dict_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_task_api_v1_tasks__task_type__post: { + parameters: { + query?: never; + header?: never; + path: { + task_type: "video" | "image" | "script" | "subtitle" | "copy" | "avatar_clone"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TaskCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskCreateResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_task_status_api_v1_tasks__task_id__get: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["app__api__v1__tasks__TaskStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_task_result_api_v1_tasks__task_id__result_get: { + parameters: { + query?: never; + header?: never; + path: { + task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health_check_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + root__get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; +} diff --git a/tauri-app/src/api/modules/avatar.ts b/tauri-app/src/api/modules/avatar.ts new file mode 100644 index 0000000..3cb8bf2 --- /dev/null +++ b/tauri-app/src/api/modules/avatar.ts @@ -0,0 +1,250 @@ +import { invoke } from '@tauri-apps/api/core'; +import { client, PYTHON_API_BASE_URL } from '../client'; + +/** + * 从 Tauri 文件存储加载 token + */ +async function loadAuthToken(): Promise { + try { + const result = await invoke<{ code: number; data?: { token?: string } }>('load_auth_state'); + if (result.code === 200 && result.data?.token) { + return result.data.token; + } + } catch (e) { + // 回退到 localStorage + const legacy = localStorage.getItem('ai-video-auth'); + if (legacy) { + try { + const parsed = JSON.parse(legacy); + return parsed?.state?.token || null; + } catch { + // ignore + } + } + } + return null; +} + +/** + * 形象(Avatar)数据模型 + * 对应 KlingAI 的 "主体 + 自定义音色" 组合 + */ +export interface AvatarItem { + /** 本地形象唯一标识(如 avt_xxx,与 Kling elementId 不同) */ + id: string; + /** 展示名称 */ + name: string; + /** Kling 自定义音色 ID */ + voiceId: string; + /** Kling 主体 ID(调用 omni-video API 时使用) */ + elementId: number; + /** 原始人物视频 URL */ + videoUrl: string; + /** 音色试听 URL */ + trialUrl?: string; + /** 创建时间 */ + recordTime: string; +} + +/** + * 形象上传及克隆参数 + * + * API 统一使用 camelCase 与后端保持一致 + */ +export interface CloneAvatarParams { + name: string; + videoUrl: string; +} + +/** + * 形象克隆响应 + */ +export interface CloneAvatarResult { + id: string; + name: string; + voiceId: string; + elementId: number; + videoUrl: string; + trialUrl?: string; + message: string; +} + +/** + * 音频生成进度事件 + */ +export interface AvatarGenerationEvent { + type: 'start' | 'preparing' | 'processing' | 'completed' | 'error'; + progress: number; + message: string; + voiceId?: string; + elementId?: number; + trialUrl?: string; +} + +/** + * 形象分类 + */ +export const AVATAR_CATEGORIES = [ + { key: 'clone', label: '克隆形象' }, + { key: 'preset', label: '系统预设' }, +]; + +/** + * 形象相关 API + */ +export const avatarApi = { + /** + * 创建形象克隆(串行:音色 → 主体 → 绑定) + * POST /avatar/clone + */ + cloneAvatar: async (params: CloneAvatarParams): Promise<{ taskId: string; status: string }> => { + return client.post<{ taskId: string; status: string }>('/avatar/clone', params); + }, + + /** + * 查询形象克隆任务状态 + * GET /avatar/tasks/:taskId + */ + getAvatarTask: async ( + taskId: string + ): Promise<{ + taskId: string; + status: string; + failReason?: string; + voiceId?: string; + elementId?: number; + trialUrl?: string; + videoUrl: string; + name: string; + createdAt: string; + updatedAt: string; + }> => { + return client.get(`/avatar/tasks/${taskId}`); + }, + + /** + * 删除形象 + * DELETE /avatar/:id + */ + deleteAvatar: async (avatarId: string, voiceId?: string): Promise => { + const query = voiceId ? `?voiceId=${encodeURIComponent(voiceId)}` : ''; + return client.delete(`/avatar/${avatarId}${query}`); + }, + + /** + * 更新形象名称 + * PATCH /avatar/{avatarId} + */ + updateAvatarName: async (avatarId: string, params: { name: string }): Promise => { + return client.patch(`/avatar/${avatarId}`, params); + }, + + /** + * 获取系统预设形象库 + * GET /avatar/presets + * + * TODO: 后端对接完成后启用 + */ + getPresetLibrary: async (): Promise => { + // return client.get("/avatar/presets"); + return []; + }, + + /** + * 获取后端克隆形象库 + * GET /avatar/library + */ + getAvatarLibrary: async (): Promise => { + return client.get('/avatar/library'); + }, + + /** + * 上传形象视频到七牛云 + * POST /qiniu/upload/avatar + */ + uploadAvatarVideo: async ( + file: File, + name?: string, + fileHash?: string + ): Promise<{ url: string; key: string; isDuplicate?: boolean; existingTaskId?: string }> => { + const formData = new FormData(); + formData.append('file', file); + if (name) { + formData.append('name', name); + } + if (fileHash) { + formData.append('fileHash', fileHash); + } + return client.postForm<{ url: string; key: string; isDuplicate?: boolean; existingTaskId?: string }>( + '/qiniu/upload/avatar', + formData + ); + }, + + /** + * SSE 流:实时跟踪形象克隆任务进度 + * GET /avatar/clone/stream + */ + cloneAvatarStream: async ( + taskId: string, + onEvent: (event: { + taskId: string; + status: string; + failReason?: string; + voiceId?: string; + elementId?: number; + trialUrl?: string; + videoUrl: string; + name: string; + }) => void, + onError?: (error: Error) => void + ): Promise<{ close: () => void }> => { + const API_BASE = import.meta.env.VITE_API_BASE_URL || PYTHON_API_BASE_URL; + const controller = new AbortController(); + + // 从 Tauri 文件存储读取 token + const token = await loadAuthToken(); + const headers: Record = { Accept: 'text/event-stream' }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + fetch(`${API_BASE}/avatar/clone/stream?taskId=${encodeURIComponent(taskId)}`, { + headers, + signal: controller.signal, + }) + .then(async response => { + if (!response.ok) { + throw new Error(`SSE error: ${response.status}`); + } + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader!.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.slice(6); + if (dataStr === '[DONE]') { + continue; + } + try { + onEvent(JSON.parse(dataStr)); + } catch (e) { + console.error('Parse SSE data error:', dataStr); + } + } + } + } + }) + .catch(error => onError?.(error as Error)); + + return { close: () => controller.abort() }; + }, +}; diff --git a/tauri-app/src/api/modules/localStorage.ts b/tauri-app/src/api/modules/localStorage.ts new file mode 100644 index 0000000..57c1d33 --- /dev/null +++ b/tauri-app/src/api/modules/localStorage.ts @@ -0,0 +1,306 @@ +/** + * 本地文件存储 API + * ================= + * + * 通过 Tauri 与 Rust 后端通信,操作本地文件系统 + * 存储位置: ~/Documents/Meijiaka/ + */ + +import { invoke } from '@tauri-apps/api/core'; +import { ApiResponse } from '../types'; + +// 检查是否在 Tauri 环境中(Tauri 2.x 兼容) +const isTauri = (): boolean => { + if (typeof window === 'undefined') return false; + // Tauri 2.x 使用 __TAURI_INTERNALS__ 或 __TAURI__ + return !!(window as any).__TAURI_INTERNALS__ || !!(window as any).__TAURI__; +}; + +// 安全调用 Tauri 命令 +const safeInvoke = async (cmd: string, args?: any): Promise => { + const tauriAvailable = isTauri(); + console.log(`[localStorage] Command: ${cmd}, Tauri available: ${tauriAvailable}`); + + if (!tauriAvailable) { + console.warn(`[localStorage] Tauri not available, command: ${cmd} skipped`); + return null; + } + + // Tauri 环境:真实调用 + try { + console.log(`[localStorage] Invoking ${cmd} with args:`, args); + const result = await invoke(cmd, args); + console.log(`[localStorage] ${cmd} result:`, result); + return result; + } catch (e) { + console.error(`[localStorage] invoke ${cmd} failed:`, e); + throw e; + } +}; + +/** + * 项目元数据接口 + */ +export interface ProjectMeta { + id: string; + title: string; + topic?: string; + status: 'draft' | 'published'; + currentStep: number; // 当前步骤:1=脚本生成, 2=视频生成, 3=字幕压制, 4=封面制作, 5=视频合成 + createdAt: number; + updatedAt: number; + coverPath?: string; + finalVideoPath?: string; + exportedAt?: number; // 最终视频合成成功的时间戳 + selectedHumanId?: string; // 选中的形象 humanId + selectedElementId?: number; // 选中的形象 elementId(Kling) + selectedVoiceId?: string; // 选中的形象 voiceId + coverConfig?: { + caption?: string; + coverStyle?: { + fontSize: number; + color: string; + strokeColor: string; + strokeWidth: number; + }; + selectedPreset?: string; + bgSource?: 'first-frame' | 'ai-generate'; + }; + scriptDuration?: number; // 脚本生成时的视频时长(秒) + scriptType?: string; // 脚本生成时的脚本类型 +} + +/** + * 字幕打轴结果 + */ +export interface AlignmentResult { + status: 'pending' | 'aligning' | 'completed' | 'failed'; + utterances?: Array<{ + text: string; + start_time: number; + end_time: number; + }>; + duration?: number; + errorMessage?: string; +} + +/** + * 分镜数据接口 + */ +export interface ProjectSegment { + id: number; + type: 'segment' | 'empty_shot'; + scene?: string; + voiceover?: string; + duration: string; + videoPath?: string; // 本地视频文件路径 + videoUrl?: string; // 七牛云视频 URL(用于字幕生成等后续处理) + elementId?: number; // 分镜使用的形象ID + voiceId?: string; // 空镜使用的音色ID + alignmentResult?: AlignmentResult; // 字幕打轴结果 + burnedVideoPath?: string; // 压制字幕后的视频路径 + burnedAt?: number; // 压制字幕的时间戳 +} + +/** + * 本地项目存储 API + */ +export const localProjectApi = { + /** + * 保存项目元数据 + */ + saveMeta: async (projectId: string, meta: ProjectMeta): Promise => { + // 按指定字段顺序序列化 + const orderedMeta = { + id: meta.id, + title: meta.title, + topic: meta.topic, + status: meta.status, + currentStep: meta.currentStep, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + coverPath: meta.coverPath, + finalVideoPath: meta.finalVideoPath, + exportedAt: meta.exportedAt, + selectedHumanId: meta.selectedHumanId, + selectedElementId: meta.selectedElementId, + selectedVoiceId: meta.selectedVoiceId, + coverConfig: meta.coverConfig, + scriptDuration: meta.scriptDuration, + scriptType: meta.scriptType, + }; + const jsonContent = JSON.stringify(orderedMeta, null, 2); + const res = await safeInvoke>('save_project_meta_raw', { + projectId: projectId, + jsonContent + }); + return res?.code === 200; + }, + + /** + * 加载项目元数据 + */ + loadMeta: async (projectId: string): Promise => { + const res = await safeInvoke>('load_project_meta', { projectId: projectId }); + if (res?.code === 200 && res.data) { + return res.data; + } + return null; + }, + + /** + * 保存分镜数据 + */ + saveSegments: async (projectId: string, segments: ProjectSegment[]): Promise => { + // 按指定字段顺序序列化每个分镜 + const orderedSegments = segments.map(s => ({ + id: s.id, + type: s.type, + scene: s.scene, + voiceover: s.voiceover, + duration: s.duration, + videoPath: s.videoPath, + videoUrl: s.videoUrl, + elementId: s.elementId, + voiceId: s.voiceId, + alignmentResult: s.alignmentResult, + burnedVideoPath: s.burnedVideoPath, + burnedAt: s.burnedAt, + })); + const jsonContent = JSON.stringify(orderedSegments, null, 2); + const res = await safeInvoke>('save_project_segments_raw', { + projectId: projectId, + jsonContent + }); + return res?.code === 200; + }, + + /** + * 加载分镜数据 + */ + loadSegments: async (projectId: string): Promise => { + const res = await safeInvoke>('load_project_segments', { projectId: projectId }); + if (res?.code === 200 && res.data) { + return res.data; + } + return []; + }, + + /** + * 列出所有本地项目 + */ + listProjects: async (): Promise => { + const res = await safeInvoke>('list_local_projects'); + if (res?.code === 200 && res.data) { + return res.data; + } + return []; + }, + + /** + * 删除本地项目 + */ + deleteProject: async (projectId: string): Promise => { + const res = await safeInvoke>('delete_local_project', { projectId: projectId }); + return res?.code === 200; + }, + + /** + * 保存完整项目(元数据 + 分镜) + */ + saveProject: async ( + projectId: string, + meta: ProjectMeta, + segments: ProjectSegment[] + ): Promise => { + const [metaRes, segmentsRes] = await Promise.all([ + localProjectApi.saveMeta(projectId, meta), + localProjectApi.saveSegments(projectId, segments) + ]); + return metaRes && segmentsRes; + }, + + /** + * 加载完整项目 + */ + loadProject: async (projectId: string): Promise<{ meta: ProjectMeta | null; segments: ProjectSegment[] }> => { + const [meta, segments] = await Promise.all([ + localProjectApi.loadMeta(projectId), + localProjectApi.loadSegments(projectId) + ]); + return { meta, segments }; + }, +}; + +/** + * 生成项目 ID + */ +export function generateProjectId(): string { + return `proj_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * 获取当前项目 ID(从 store 或生成新的) + */ +export function getCurrentProjectId(): string { + // 可以从 URL 参数、store 或 localStorage 获取 + // 这里简化处理,实际使用时需要更复杂的逻辑 + const stored = localStorage.getItem('current-project-id'); + if (stored) { + return stored; + } + const newId = generateProjectId(); + localStorage.setItem('current-project-id', newId); + return newId; +} + +/** + * 设置当前项目 ID + */ +export function setCurrentProjectId(projectId: string): void { + localStorage.setItem('current-project-id', projectId); +} + +/** + * 保存 Blob 到项目 assets 目录 + * 返回最终本地文件路径 + */ +export async function saveBlobToProject( + projectId: string, + blob: Blob, + filename: string +): Promise { + // 先将 Blob 转为 base64 + const reader = new FileReader(); + const base64Data = await new Promise((resolve, reject) => { + reader.onloadend = () => { + const result = reader.result as string; + // 去掉 data:image/png;base64, 前缀 + const data = result.split(',')[1]; + resolve(data); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + // 调用 Tauri 命令保存到本地 + const res = await safeInvoke>('save_project_asset', { + projectId, + filename, + base64Data, + }); + + if (!res || res.code !== 200 || !res.data) { + throw new Error('保存文件失败'); + } + + return res.data; +} + +/** + * 将远程图片 URL 转为 Blob + */ +export async function convertImageUrlToBlob(url: string): Promise { + const response = await fetch(url); + return await response.blob(); +} diff --git a/tauri-app/src/api/modules/project.ts b/tauri-app/src/api/modules/project.ts new file mode 100644 index 0000000..1196b7f --- /dev/null +++ b/tauri-app/src/api/modules/project.ts @@ -0,0 +1,130 @@ +import { client } from '../client'; +import type { ScriptShot } from '../types'; + +export type { ScriptShot }; + +/** + * 项目类型 + */ +export interface Project { + id: string; + userId: string; + title: string | null; + topic: string | null; + scriptType: string | null; + duration: number | null; + globalAudioSettings: Record | null; + status: 'draft' | 'editing' | 'completed'; + createdAt: string; + updatedAt: string; +} + +/** + * 创建项目请求 + */ +export interface CreateProjectParams { + title?: string; + topic?: string; + scriptType?: string; + duration?: number; + globalAudioSettings?: Record; + status?: 'draft' | 'editing' | 'completed'; +} + +/** + * 更新项目请求 + */ +export interface UpdateProjectParams { + title?: string; + topic?: string; + scriptType?: string; + duration?: number; + globalAudioSettings?: Record; + status?: 'draft' | 'editing' | 'completed'; +} + +/** + * 保存分镜请求 + */ +export interface SaveSegmentsParams { + segments: Array<{ + sequence: number; + type: 'segment' | 'empty_shot'; + scene?: string; + prompt?: string; + voiceover?: string; + duration?: string; + }>; +} + +/** + * 项目列表响应 + */ +export interface ProjectListResponse { + total: number; + items: Project[]; +} + +/** + * 项目 API + */ +export const projectApi = { + /** + * 创建项目 + * POST /projects + */ + create: async (params: CreateProjectParams): Promise => { + return client.post('/projects', params); + }, + + /** + * 获取项目列表 + * GET /projects + */ + list: async (skip = 0, limit = 100): Promise => { + return client.get(`/projects?skip=${skip}&limit=${limit}`); + }, + + /** + * 获取项目详情(含分镜) + * GET /projects/{id} + */ + get: async (projectId: string): Promise => { + return client.get(`/projects/${projectId}`); + }, + + /** + * 更新项目 + * PUT /projects/{id} + */ + update: async (projectId: string, params: UpdateProjectParams): Promise => { + return client.put(`/projects/${projectId}`, params); + }, + + /** + * 删除项目 + * DELETE /projects/{id} + */ + delete: async (projectId: string): Promise<{ deleted: boolean }> => { + return client.delete<{ deleted: boolean }>(`/projects/${projectId}`); + }, + + /** + * 保存/替换项目分镜 + * PUT /projects/{id}/segments + */ + saveSegments: async ( + projectId: string, + segments: SaveSegmentsParams['segments'] + ): Promise => { + return client.put(`/projects/${projectId}/segments`, segments); + }, + + /** + * 获取项目分镜 + * GET /projects/{id}/segments + */ + getSegments: async (projectId: string): Promise => { + return client.get(`/projects/${projectId}/segments`); + }, +}; diff --git a/tauri-app/src/api/modules/script.ts b/tauri-app/src/api/modules/script.ts new file mode 100644 index 0000000..524b6ca --- /dev/null +++ b/tauri-app/src/api/modules/script.ts @@ -0,0 +1,110 @@ +import { client } from '../client'; +import type { ScriptShot } from '../types'; + +export type { ScriptShot }; + +/** + * 脚本生成请求参数 + */ +export interface GenerateScriptParams { + topic: string; + duration: number; // 秒 + type: string; // 脚本类型描述 +} + +/** + * 模型健康状态 + */ +export interface ModelHealth { + id: string; + name: string; + isAvailable: boolean; + responseTime: number; + lastError: string | null; +} + +/** + * 模型健康检查响应 + */ +export interface ModelHealthResponse { + status: 'healthy' | 'unhealthy' | 'error'; + models: ModelHealth[]; + recommendedModel: ModelHealth | null; + totalModels: number; + availableModels: number; + error?: string; +} + +/** + * 测试模型请求 + */ +export interface TestModelRequest { + modelId?: string; +} + +/** + * 测试模型响应 + */ +export interface TestModelResponse { + success: boolean; + model: string; + responseTime?: number; + error?: string; + checkedAt?: string; +} + +/** + * 脚本相关 API + */ +export const scriptApi = { + /** + * 生成脚本内容(同步) + * POST /script/generate + */ + generate: async (params: GenerateScriptParams): Promise => { + return client.post('/script/generate', { + topic: params.topic, + duration: params.duration, + scriptType: params.type, + }); + }, + + /** + * AI 润色脚本 + * POST /script/polish + * + * @param shotType 镜头类型:'segment'(分镜) 或 'empty_shot'(空镜),用于画面润色时区分 + */ + polish: async ( + _segmentId: number, + content: string, + polishType: 'scene' | 'prompt' | 'voiceover' = 'voiceover', + shotType: 'segment' | 'empty_shot' = 'segment' + ): Promise => { + const backendType = polishType === 'prompt' ? 'scene' : polishType; + return client.post('/script/polish', { + content: content, + polishType: backendType, + shotType: shotType, + }); + }, + + /** + * 检查模型健康状态 + * GET /script/model-health + */ + checkModelHealth: async (): Promise => { + return client.get('/script/model-health'); + }, + + /** + * 测试指定模型连接 + * POST /script/test-model?modelId=xxx + */ + testModel: async (modelId?: string): Promise => { + const path = modelId + ? `/script/test-model?modelId=${encodeURIComponent(modelId)}` + : '/script/test-model'; + return client.post(path); + }, +}; diff --git a/tauri-app/src/api/modules/task.ts b/tauri-app/src/api/modules/task.ts new file mode 100644 index 0000000..073a0fe --- /dev/null +++ b/tauri-app/src/api/modules/task.ts @@ -0,0 +1,56 @@ +/** + * 任务管理 API + * ============ + * + * 提供任务列表查询接口。 + * 任务状态以后端 Redis 为唯一真相源,前端不持久化。 + */ + +import { client } from '../client'; +import type { TaskType, TaskStatus } from '../../store/taskStore'; + +export interface TaskItem { + taskId: string; + type: TaskType; + status: TaskStatus; + progress: number; + message: string; + completed: number; + total: number; + result?: unknown; + error?: string; +} + +/** + * 查询当前用户进行中的任务列表 + * @param projectId 可选,按项目过滤 + */ +export async function listTasks(projectId?: string): Promise { + const query = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''; + const data = await client.get< + Array<{ + task_id: string; + type: TaskType; + status: TaskStatus; + progress: number; + message: string; + completed: number; + total: number; + result?: unknown; + error?: string; + }> + >(`/tasks${query}`); + + // snake_case → camelCase 转换 + return data.map((item) => ({ + taskId: item.task_id, + type: item.type, + status: item.status, + progress: item.progress, + message: item.message, + completed: item.completed, + total: item.total, + result: item.result, + error: item.error, + })); +} diff --git a/tauri-app/src/api/modules/videoComposite.ts b/tauri-app/src/api/modules/videoComposite.ts new file mode 100644 index 0000000..83d3a98 --- /dev/null +++ b/tauri-app/src/api/modules/videoComposite.ts @@ -0,0 +1,28 @@ +import { invoke } from '@tauri-apps/api/core'; +import { ApiResponse } from '../types'; + +export interface VideoCompositeRequest { + video_paths: string[]; + audio_path?: string; + cover_path?: string; + output_path: string; +} + +/** + * 视频合成 API(走 Tauri IPC 直接命令) + */ +export const compositeApi = { + /** + * 执行视频合成 + */ + synthesis: async (request: VideoCompositeRequest): Promise => { + const response = await invoke>('video_composite_synthesis', { request }); + if (!response || typeof response !== 'object') { + throw new Error('IPC request returned invalid response'); + } + if ('code' in response && response.code !== 200) { + throw new Error(response.message || '视频合成失败'); + } + return response.data as string; + }, +}; diff --git a/tauri-app/src/api/types.ts b/tauri-app/src/api/types.ts new file mode 100644 index 0000000..fb530b6 --- /dev/null +++ b/tauri-app/src/api/types.ts @@ -0,0 +1,58 @@ +/** + * 通用 API 响应格式 + */ +export interface ApiResponse { + code: number; + data: T; + message: string; +} + +/** + * 分页请求参数 + */ +export interface PageParams { + page: number; + pageSize: number; +} + +/** + * 分页响应包装 + */ +export interface PageResponse { + items: T[]; + total: number; + hasMore: boolean; +} + +/** + * 字幕打轴结果 + */ +export interface AlignmentResult { + status: 'pending' | 'aligning' | 'completed' | 'failed'; + utterances?: Array<{ + text: string; + start_time: number; + end_time: number; + }>; + duration?: number; + errorMessage?: string; +} + +/** + * 脚本镜头定义(分镜或空镜) + * 前后端统一的权威类型 + */ +export interface ScriptShot { + id: number; + type: 'segment' | 'empty_shot'; + scene?: string; // 画面描述 + voiceover: string; // 配音文案 + duration: string; + videoPath?: string; // 本地视频文件路径 + videoUrl?: string; // 七牛云视频 URL(用于字幕生成等后续处理) + elementId?: number; // 分镜使用的形象ID(Kling element_id) + voiceId?: string; // 空镜使用的音色ID + alignmentResult?: AlignmentResult; // 字幕打轴结果 + burnedVideoPath?: string; // 压制字幕后的视频路径 + burnedAt?: number; // 压制字幕的时间戳 +} diff --git a/tauri-app/src/assets/react.svg b/tauri-app/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/tauri-app/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tauri-app/src/components/ErrorBoundary/ErrorBoundary.css b/tauri-app/src/components/ErrorBoundary/ErrorBoundary.css new file mode 100644 index 0000000..aacd127 --- /dev/null +++ b/tauri-app/src/components/ErrorBoundary/ErrorBoundary.css @@ -0,0 +1,65 @@ +/** + * ErrorBoundary 样式 + */ + +.error-boundary { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-2xl); + text-align: center; + min-height: 300px; +} + +.error-boundary-icon { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--error-light, rgb(239 68 68 / 10%)); + color: var(--error); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--spacing-lg); +} + +.error-boundary-title { + font-size: var(--font-lg); + font-weight: 600; + color: var(--text-primary); + margin: 0 0 var(--spacing-sm) 0; +} + +.error-boundary-message { + font-size: var(--font-base); + color: var(--text-secondary); + margin: 0 0 var(--spacing-xl) 0; + max-width: 400px; +} + +.error-boundary-details { + width: 100%; + max-width: 600px; + margin-bottom: var(--spacing-lg); + text-align: left; +} + +.error-boundary-details summary { + font-size: var(--font-sm); + color: var(--text-tertiary); + cursor: pointer; + user-select: none; +} + +.error-boundary-stack { + background: var(--bg-tertiary); + border-radius: var(--radius-md); + padding: var(--spacing-md); + font-size: var(--font-sm); + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + overflow: auto; + margin-top: var(--spacing-sm); + max-height: 200px; +} diff --git a/tauri-app/src/components/ErrorBoundary/ErrorBoundary.tsx b/tauri-app/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..fb8482f --- /dev/null +++ b/tauri-app/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,128 @@ +/** + * Error Boundary 组件 + * =================== + * + * 捕获 React 组件树中的错误,防止整个应用崩溃 + * 支持错误上报和优雅降级 + */ + +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import './ErrorBoundary.css'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +/** + * 错误边界组件 + * + * 使用方式: + * + * + * + * + * 或者使用自定义 fallback: + * }> + * + * + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null, errorInfo: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error, errorInfo: null }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + this.setState({ errorInfo }); + + // 调用外部错误处理回调 + this.props.onError?.(error, errorInfo); + + // 控制台输出 + console.error('[ErrorBoundary] Caught error:', error); + console.error('[ErrorBoundary] Component stack:', errorInfo.componentStack); + + // 这里可以添加错误上报逻辑,如 Sentry + // reportError(error, errorInfo); + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: null, errorInfo: null }); + }; + + render(): ReactNode { + if (this.state.hasError) { + // 使用自定义 fallback + if (this.props.fallback) { + return this.props.fallback; + } + + // 默认错误页面 + return ( +
+
+ + + + + +
+

出错了

+

组件渲染时发生错误,请刷新页面或返回重试

+ {import.meta.env.DEV && this.state.error && ( +
+ 错误详情(仅开发环境) +
+                {this.state.error.toString()}
+                {'\n'}
+                {this.state.errorInfo?.componentStack}
+              
+
+ )} + +
+ ); + } + + return this.props.children; + } +} + +/** + * 页面级错误边界(包裹整个页面) + */ +export function withErrorBoundary

( + Component: React.ComponentType

, + fallback?: ReactNode +): React.FC

{ + return function WrappedComponent(props: P) { + return ( + + + + ); + }; +} + +export default ErrorBoundary; diff --git a/tauri-app/src/components/ErrorBoundary/index.ts b/tauri-app/src/components/ErrorBoundary/index.ts new file mode 100644 index 0000000..36795d7 --- /dev/null +++ b/tauri-app/src/components/ErrorBoundary/index.ts @@ -0,0 +1,2 @@ +export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary'; +export { default } from './ErrorBoundary'; diff --git a/tauri-app/src/components/Layout/Sidebar.css b/tauri-app/src/components/Layout/Sidebar.css new file mode 100644 index 0000000..7357faa --- /dev/null +++ b/tauri-app/src/components/Layout/Sidebar.css @@ -0,0 +1,253 @@ +.sidebar { + width: var(--sidebar-width); + height: 100vh; + display: flex; + flex-direction: column; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); + border-right: 1px solid var(--border-light); + position: fixed; + left: 0; + top: 0; + z-index: 100; + transition: width var(--transition-normal); +} + +.sidebar-header { + padding: var(--spacing-xl) var(--spacing-sm); + border-bottom: 1px solid var(--border-light); +} + +.sidebar-logo { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); +} + +.sidebar-title { + font-size: var(--font-lg); + font-weight: 700; + background: var(--primary-gradient); + background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md) var(--spacing-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-2xs); +} + +.nav-group { + display: flex; + flex-direction: column; +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-md); + border-radius: var(--radius-md); + color: var(--text-secondary); + background: transparent; + border: none; + cursor: pointer; + font-size: var(--font-base); + font-family: var(--font-family); + width: 100%; + text-align: left; + transition: all var(--transition-fast); + position: relative; +} + +.nav-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-item.active, +.nav-item.child-active { + background: var(--primary-light); + color: var(--primary); + font-weight: 500; +} + +.nav-icon { + flex-shrink: 0; +} + +.nav-label { + flex: 1; +} + +.nav-new-project { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-md); + color: rgba(74, 222, 128, 0.9); + background: transparent; + border: none; + cursor: pointer; + padding: 0; + margin-left: auto; + margin-right: 0; + transform: translateY(1px); + transition: all 0.2s ease; +} + +.nav-new-project:hover { + transform: translateY(1px) scale(1.08); +} + +.nav-new-project:active { + transform: translateY(1px) scale(0.96); +} + +.nav-chevron { + flex-shrink: 0; + transition: transform var(--transition-fast); +} + +.nav-chevron.expanded { + transform: rotate(90deg); +} + +.nav-children { + display: flex; + flex-direction: column; + padding-left: 4px; /* Reduced indentation as requested */ + gap: var(--spacing-2xs); + animation: fadeIn 0.2s ease; +} + +.nav-child-item { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + color: var(--text-secondary); + background: transparent; + border: none; + cursor: pointer; + font-size: var(--font-sm); + font-family: var(--font-family); + width: 100%; + text-align: left; + transition: all var(--transition-fast); +} + +.nav-child-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-child-item.active { + color: var(--primary); + font-weight: 500; +} + +.sidebar-footer { + padding: var(--spacing-md); + border-top: 1px solid var(--border-light); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.sidebar-user { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast); +} + +.sidebar-user:hover { + background: var(--bg-hover); +} + +.sidebar-divider { + height: 1px; + background: var(--border-light); + margin: var(--spacing-xs) 0; +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + background: var(--primary-gradient); + color: var(--text-inverse); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: var(--font-sm); + flex-shrink: 0; +} + +.user-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.user-name { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-role { + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +.sidebar-logout { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-md); + color: var(--text-tertiary); + background: transparent; + border: none; + cursor: pointer; + font-size: var(--font-xs); + font-family: var(--font-family); + width: 100%; + text-align: center; + transition: all var(--transition-fast); +} + +.sidebar-logout:hover { + background: var(--bg-hover); + color: var(--text-secondary); +} + +.sidebar-logout svg { + flex-shrink: 0; + opacity: 0.7; +} + +.sidebar-logout:hover svg { + opacity: 1; +} diff --git a/tauri-app/src/components/Layout/Sidebar.tsx b/tauri-app/src/components/Layout/Sidebar.tsx new file mode 100644 index 0000000..890d340 --- /dev/null +++ b/tauri-app/src/components/Layout/Sidebar.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { createNewProject } from '../../store'; +import './Sidebar.css'; + +interface NavItem { + id: string; + label: string; + icon: string; + children?: { id: string; label: string }[]; +} + +const navItems: NavItem[] = [ + { + id: 'video-creation', + label: '视频创作', + icon: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z', + }, + { + id: 'content-management', + label: '内容管理', + icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', + children: [ + { id: 'avatar-clone', label: '形象克隆' }, + { id: 'my-works', label: '我的作品' }, + ], + }, + { + id: 'settings', + label: '系统设置', + icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', + children: [ + { id: 'theme-settings', label: '主题设置' }, + { id: 'about-us', label: '关于我们' }, + { id: 'system-update', label: '系统更新' }, + ], + }, +]; + +interface SidebarProps { + currentPath: string; + onNavigate: (path: string) => void; +} + +export default function Sidebar({ currentPath, onNavigate }: SidebarProps) { + const [expandedItems, setExpandedItems] = useState>( + new Set(['content-management', 'settings']) + ); + + const toggleExpand = (id: string) => { + setExpandedItems(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleClick = (item: NavItem) => { + if (item.children) { + toggleExpand(item.id); + // Navigate to first child + if (!expandedItems.has(item.id)) { + onNavigate(item.children[0].id); + } + } else { + onNavigate(item.id); + } + }; + + const handleNewProject = async (e: React.MouseEvent) => { + e.stopPropagation(); + await createNewProject(); + onNavigate('video-creation'); + }; + + const isActive = (id: string) => currentPath === id; + + return ( +

+ ); +} diff --git a/tauri-app/src/components/Modal/AvatarUploadModal.css b/tauri-app/src/components/Modal/AvatarUploadModal.css new file mode 100644 index 0000000..fcbbec0 --- /dev/null +++ b/tauri-app/src/components/Modal/AvatarUploadModal.css @@ -0,0 +1,115 @@ +/* ============================================ + 形象克隆上传弹窗 + ============================================ */ + +.clone-upload-modal { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.clone-upload-form { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.form-field { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.form-field label { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); +} + +.form-field input[type='text'] { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + font-size: var(--font-sm); + transition: border-color var(--transition-fast); +} + +.form-field input[type='text']:focus { + outline: none; + border-color: var(--primary); +} + +/* 文件拖放区域 */ +.file-drop-zone { + padding: var(--spacing-xl); + border: 2px dashed var(--border-color); + border-radius: var(--radius-lg); + background: var(--bg-input); + cursor: pointer; + transition: all var(--transition-fast); + text-align: center; +} + +.file-drop-zone:hover { + border-color: var(--primary); + background: var(--bg-hover); +} + +.file-drop-zone.has-file { + border-color: var(--success); + background: rgb(34 197 94 / 5%); +} + +.file-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-sm); + color: var(--text-secondary); +} + +.file-placeholder svg { + color: var(--text-tertiary); +} + +.file-hint { + font-size: var(--font-xs); + color: var(--text-tertiary); + text-align: center; + line-height: 1.6; +} + +/* 已选文件信息 */ +.file-info { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); +} + +.file-info svg { + color: var(--success); +} + +.file-name { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); + word-break: break-all; +} + +.file-size { + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +/* 弹窗底部按钮 */ +.clone-upload-actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--border-light); +} + +/* 处理中状态 - 进度显示已由 ProgressModal.css 接管 */ diff --git a/tauri-app/src/components/Modal/AvatarUploadModal.tsx b/tauri-app/src/components/Modal/AvatarUploadModal.tsx new file mode 100644 index 0000000..9e6a321 --- /dev/null +++ b/tauri-app/src/components/Modal/AvatarUploadModal.tsx @@ -0,0 +1,470 @@ +import { useState, useRef, useEffect } from 'react'; +import type { ChangeEvent } from 'react'; +import Modal from './Modal'; +import { avatarApi, type AvatarItem } from '../../api/modules/avatar'; +import { calculateFileSHA256 } from '../../utils/fileHash'; +import { useTask } from '../../hooks/useTask'; +import { addClonedAvatarToLocal } from '../../utils/avatarStorage'; +import { toast } from '../../store/uiStore'; +import './AvatarUploadModal.css'; +import '../ProgressModal/ProgressModal.css'; + +interface AvatarUploadModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +type UploadStep = 'idle' | 'uploading' | 'cloning' | 'completed' | 'error'; + +export default function AvatarUploadModal({ open, onClose, onSuccess }: AvatarUploadModalProps) { + const [avatarName, setAvatarName] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [currentStep, setCurrentStep] = useState('idle'); + const [statusText, setStatusText] = useState(''); + + const fileInputRef = useRef(null); + const isActiveRef = useRef(true); + const avatarNameRef = useRef(avatarName); + const completedRef = useRef(false); + + useEffect(() => { + avatarNameRef.current = avatarName; + }, [avatarName]); + + const { submit } = useTask(); + + const resetState = () => { + setAvatarName(''); + setSelectedFile(null); + setCurrentStep('idle'); + setStatusText(''); + }; + + const cleanup = () => { + isActiveRef.current = false; + }; + + useEffect(() => { + if (!open) { + // Modal 关闭时重置内部状态(避免 reopen 时显示旧数据) + // eslint-disable-next-line react-hooks/set-state-in-effect + resetState(); + } + return () => { + cleanup(); + }; + }, [open]); + + const validateVideoFile = (file: File): Promise<{ valid: boolean; error?: string }> => { + return new Promise(resolve => { + const allowedTypes = ['video/mp4', 'video/quicktime']; + const allowedExts = ['.mp4', '.mov']; + const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); + + if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) { + resolve({ valid: false, error: '仅支持 MP4、MOV 格式' }); + return; + } + + const maxSize = 200 * 1024 * 1024; + if (file.size > maxSize) { + resolve({ valid: false, error: '文件大小不能超过 200MB' }); + return; + } + + const video = document.createElement('video'); + video.preload = 'metadata'; + + video.onloadedmetadata = () => { + const duration = video.duration; + const height = video.videoHeight; + URL.revokeObjectURL(video.src); + + if (duration < 5) { + resolve({ + valid: false, + error: `视频时长 ${duration.toFixed(1)} 秒,要求至少 5 秒(建议 5-8 秒)`, + }); + return; + } + if (duration > 8) { + resolve({ + valid: false, + error: `视频时长 ${duration.toFixed(1)} 秒,要求不超过 8 秒(建议 5-8 秒)`, + }); + return; + } + if (height && (height < 720 || height > 2160)) { + resolve({ valid: false, error: `视频高度为 ${height}px,要求高度在 720px~2160px 之间` }); + return; + } + resolve({ valid: true }); + }; + + video.onerror = () => { + URL.revokeObjectURL(video.src); + resolve({ valid: false, error: '无法读取视频文件,请检查文件是否损坏' }); + }; + + setTimeout(() => { + URL.revokeObjectURL(video.src); + resolve({ valid: false, error: '读取视频超时,请重试' }); + }, 8000); + + video.src = URL.createObjectURL(file); + }); + }; + + const handleFileSelect = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + const validation = await validateVideoFile(file); + if (!validation.valid) { + toast.error(validation.error || '视频验证失败'); + e.target.value = ''; + return; + } + + setSelectedFile(file); + }; + + + + // 将任务结果保存到本地形象库 + const saveAvatarResult = (result: unknown) => { + // ⚠️ 注意:client.ts 的 parseResponse 会对嵌套对象做 snakeToCamel 转换 + const r = result as + | { + avatarId?: string; + name?: string; + videoUrl?: string; + voiceId?: string; + elementId?: number; + trialUrl?: string; + } + | undefined; + if (r?.avatarId) { + const avatar: AvatarItem = { + id: r.avatarId, + name: r.name || avatarNameRef.current.trim() || '未命名形象', + voiceId: r.voiceId || '', + elementId: r.elementId || 0, + videoUrl: r.videoUrl || '', + trialUrl: r.trialUrl, + recordTime: new Date().toISOString(), + }; + addClonedAvatarToLocal(avatar).catch((err) => { + console.error('[AvatarUpload] Failed to save avatar:', err); + }); + } else { + // 缺少 avatarId,不保存到本地 + } + }; + + // 统一处理任务完成 + const handleTaskComplete = (result: unknown) => { + if (completedRef.current || !isActiveRef.current) { + return; + } + completedRef.current = true; + saveAvatarResult(result); + setCurrentStep('completed'); + setStatusText('创建成功!'); + // 不自动关闭,等用户点击"确定" + }; + + // 统一处理任务失败 + const handleTaskError = (error: string) => { + if (completedRef.current || !isActiveRef.current) { + return; + } + completedRef.current = true; + setCurrentStep('error'); + setStatusText(error || '创建失败'); + }; + + // 处理任务状态更新 + const handleTaskProgress = (taskStatus: string, _progress: number, message: string) => { + if (!isActiveRef.current || completedRef.current) { + return; + } + + // 更新状态文本 + if (message) { + setStatusText(message); + } + + switch (taskStatus) { + case 'failed': + handleTaskError(message || '创建失败'); + break; + case 'completed': + // 完成状态由 onComplete 回调统一处理 + break; + case 'pending': + case 'running': + // 保持当前状态,继续处理 + break; + default: + // 其他状态,保持显示 + break; + } + }; + + const handleUpload = async () => { + if (!avatarName.trim()) { + return; + } + if (!selectedFile) { + return; + } + + isActiveRef.current = true; + completedRef.current = false; + setCurrentStep('uploading'); + setStatusText('正在计算文件指纹...'); + + try { + // 计算文件哈希用于重复检测 + const fileHash = await calculateFileSHA256(selectedFile); + // fileHash 用于重复检测 + + setStatusText('正在上传视频...'); + + const uploadResult = await avatarApi.uploadAvatarVideo( + selectedFile, + avatarName.trim(), + fileHash + ); + + setCurrentStep('cloning'); + setStatusText('正在提交克隆任务...'); + + // 使用统一任务 API 提交形象克隆任务 + await submit( + 'avatar_clone', + { + name: avatarName.trim(), + videoUrl: uploadResult.url, + }, + { + showProgress: false, // 我们自己管理进度 UI + callbacks: { + onProgress: handleTaskProgress, + onComplete: (result) => { + handleTaskComplete(result); + }, + onError: (error) => { + handleTaskError(error); + }, + }, + } + ); + + setStatusText('等待任务调度...'); + } catch (error: unknown) { + const err = error as { name?: string; message?: string }; + if (err.name === 'AbortError') { + setStatusText('已取消'); + } else { + setCurrentStep('error'); + setStatusText(err.message || '创建失败'); + } + } + }; + + return ( + <> + {/* 非处理状态:普通 Modal 上传表单 */} + {currentStep === 'idle' && ( + +
+
+
+ + setAvatarName(e.target.value)} + disabled={currentStep !== 'idle'} + /> +
+ +
+ +
fileInputRef.current?.click()} + > + + {selectedFile ? ( +
+ + + + + + + + + + + {selectedFile.name} + + {(selectedFile.size / 1024 / 1024).toFixed(2)} MB + +
+ ) : ( +
+ + + + + + 点击选择视频文件 + + 支持 MP4 / MOV 格式,大小 ≤ 200MB +
+ 建议时长 5-8 秒,高度 720px ~ 2160px +
+ 需含清晰人声和人脸正面特写 +
+
+ )} +
+
+
+ +
+ + +
+
+
+ )} + + {/* 处理状态:全屏 ProgressModal 样式 Overlay */} + {currentStep !== 'idle' && open && ( +
+
+ {/* 头部 */} +
+
+ + + + +
+

形象克隆

+
+ + {/* 主体内容 */} +
+ {currentStep === 'completed' ? ( +
+
+ + + +
+

{statusText || '创建成功!'}

+
+ ) : currentStep === 'error' ? ( +
+
+ + + + + +
+

{statusText || '发生错误'}

+
+ ) : ( + <> +
+
+
+
+ {statusText} +
+ + )} +
+ + {/* 底部操作区 */} +
+ {currentStep === 'completed' && ( + + )} + {currentStep === 'error' && ( + + )} + {currentStep !== 'completed' && currentStep !== 'error' && ( + + 处理中,请勿退出应用 + + + )} +
+
+
+ )} + + ); +} diff --git a/tauri-app/src/components/Modal/ConfirmModal.css b/tauri-app/src/components/Modal/ConfirmModal.css new file mode 100644 index 0000000..2b22535 --- /dev/null +++ b/tauri-app/src/components/Modal/ConfirmModal.css @@ -0,0 +1,159 @@ +/* 确认弹窗样式 - 统一设计规范 */ + +.confirm-modal-overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(2px); + animation: confirmFadeIn 0.2s ease; +} + +.confirm-modal-container { + position: relative; + background: var(--bg-card); + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 360px; + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + animation: confirmSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* 关闭按钮 */ +.confirm-modal-close { + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + border: none; + transition: all 0.15s ease; +} + +.confirm-modal-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* 图标 */ +.confirm-modal-icon { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--bg-input); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; +} + +/* 标题 */ +.confirm-modal-title { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); + text-align: center; + line-height: 1.5; + margin-bottom: 20px; + word-break: break-word; +} + +.confirm-modal-title strong { + font-weight: 600; + color: var(--text-primary); +} + +/* 描述 */ +.confirm-modal-description { + font-size: 13px; + color: var(--text-secondary); + text-align: center; + line-height: 1.5; + margin-top: -12px; + white-space: pre-line; + margin-bottom: 20px; +} + +/* 按钮组 */ +.confirm-modal-actions { + display: flex; + gap: 10px; + width: 100%; +} + +.confirm-modal-btn { + flex: 1; + height: 40px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +/* 取消按钮 */ +.confirm-modal-btn.cancel { + background: var(--bg-input); + color: var(--text-secondary); + border: 1px solid var(--border-light); +} + +.confirm-modal-btn.cancel:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* 确认按钮 - 主色 */ +.confirm-modal-btn.primary { + background: var(--primary); + color: white; +} + +.confirm-modal-btn.primary:hover { + background: var(--primary-hover); +} + +/* 确认按钮 - 危险色(统一使用绿色主色调) */ +.confirm-modal-btn.danger { + background: var(--primary); + color: white; +} + +.confirm-modal-btn.danger:hover { + background: var(--primary-hover); +} + +/* 动画 */ +@keyframes confirmFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes confirmSlideUp { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/tauri-app/src/components/Modal/ConfirmModal.tsx b/tauri-app/src/components/Modal/ConfirmModal.tsx new file mode 100644 index 0000000..2255d29 --- /dev/null +++ b/tauri-app/src/components/Modal/ConfirmModal.tsx @@ -0,0 +1,141 @@ +/** + * 确认弹窗组件 - 统一样式 + * ======================== + * + * 使用场景:删除确认、退出确认、危险操作确认 + * + * 示例: + * } + * title={<>确认删除形象 「{name}」 吗?} + * description="此操作不可撤销,本地文件将同时被清除" + * confirmText="确认删除" + * onConfirm={handleConfirm} + * onCancel={handleCancel} + * /> + */ + +import './ConfirmModal.css'; + +interface ConfirmModalProps { + open: boolean; + onClose?: () => void; + onConfirm: () => void; + onCancel: () => void; + type?: 'warning' | 'danger' | 'info'; + icon?: React.ReactNode; + title: React.ReactNode; + description?: string; + confirmText?: string; + cancelText?: string; + confirmButtonType?: 'primary' | 'danger'; +} + +// 默认图标 - 警告 +const WarningIcon = () => ( + + + + + +); + +// 默认图标 - 信息 +const InfoIcon = () => ( + + + + + +); + +export default function ConfirmModal({ + open, + onClose, + onConfirm, + onCancel, + type = 'warning', + icon, + title, + description, + confirmText = '确认', + cancelText = '取消', + confirmButtonType = 'primary', +}: ConfirmModalProps) { + if (!open) return null; + + // 根据类型选择默认图标 + const getDefaultIcon = () => { + switch (type) { + case 'danger': + case 'warning': + return ; + case 'info': + default: + return ; + } + }; + + // 图标颜色(统一使用灰色调) + const getIconColor = () => { + switch (type) { + case 'danger': + case 'warning': + case 'info': + default: + return 'var(--text-tertiary)'; // 灰色 + } + }; + + const handleClose = () => { + onCancel(); + onClose?.(); + }; + + const handleConfirm = () => { + onConfirm(); + onClose?.(); + }; + + return ( +
+
e.stopPropagation()}> + {/* 关闭按钮 */} + + + {/* 图标 */} +
+ {icon || getDefaultIcon()} +
+ + {/* 标题 */} +
{title}
+ + {/* 描述 */} + {description && ( +
{description}
+ )} + + {/* 按钮组 */} +
+ + +
+
+
+ ); +} diff --git a/tauri-app/src/components/Modal/Modal.css b/tauri-app/src/components/Modal/Modal.css new file mode 100644 index 0000000..14fa040 --- /dev/null +++ b/tauri-app/src/components/Modal/Modal.css @@ -0,0 +1,59 @@ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-overlay); + animation: fadeIn 0.15s ease; +} + +.modal-container { + background: var(--bg-card); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + width: 90%; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border-light); +} + +.modal-title { + font-size: var(--font-lg); + font-weight: 600; +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + border: none; + transition: all var(--transition-fast); +} + +.modal-close:hover { + background: var(--bg-input); + color: var(--text-primary); +} + +.modal-body { + padding: var(--spacing-md); + overflow-y: auto; + flex: 1; +} diff --git a/tauri-app/src/components/Modal/Modal.tsx b/tauri-app/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..313d058 --- /dev/null +++ b/tauri-app/src/components/Modal/Modal.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react'; +import './Modal.css'; + +interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + width?: string; + centerTitle?: boolean; +} + +export default function Modal({ + open, + onClose, + title, + children, + width = '560px', + centerTitle = false, +}: ModalProps) { + const overlayRef = useRef(null); + + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + if (!open) { + return null; + } + + return ( +
{ + if (e.target === overlayRef.current) { + onClose(); + } + }} + > +
+ {title && ( +
+

+ {title} +

+ +
+ )} +
{children}
+
+
+ ); +} diff --git a/tauri-app/src/components/ProgressModal/ProgressModal.css b/tauri-app/src/components/ProgressModal/ProgressModal.css new file mode 100644 index 0000000..7444bd1 --- /dev/null +++ b/tauri-app/src/components/ProgressModal/ProgressModal.css @@ -0,0 +1,279 @@ +/** + * 全局进度弹窗样式 + */ + +.progress-modal-overlay { + position: fixed; + inset: 0; + z-index: 1300; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + animation: progressModalFadeIn 0.2s ease; +} + +/* 运行中状态 - 禁止关闭的提示 */ +.progress-modal-overlay.running { + cursor: not-allowed; +} + +.progress-modal-overlay.running .progress-modal-container { + cursor: default; +} + +.progress-modal-container { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: var(--radius-xl, 16px); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.1), + 0 2px 8px rgba(0, 0, 0, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.3); + width: 90%; + max-width: 360px; + padding: 28px 24px; + display: flex; + flex-direction: column; + gap: 20px; + animation: progressModalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* 头部标题 */ +.progress-modal-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.progress-modal-icon-wrapper { + width: 56px; + height: 56px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(34, 197, 94, 0.05) 100%); + display: flex; + align-items: center; + justify-content: center; +} + +.progress-modal-icon-animated { + color: var(--primary, #22c55e); + animation: iconPulse 2s ease-in-out infinite; +} + +@keyframes iconPulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.8; + } +} + +.progress-modal-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin: 0; +} + +/* 进度条区域 */ +.progress-modal-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.progress-modal-bar-container { + width: 100%; + height: 12px; + background: #e8e8e8; + border-radius: 6px; + overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progress-modal-bar-fill { + height: 100%; + background: linear-gradient(180deg, #4ade80 0%, #22c55e 50%, #16a34a 100%); + border-radius: 6px; + position: relative; + box-shadow: 0 1px 2px rgba(34, 197, 94, 0.3); +} + +/* 不确定进度条(running 状态) */ +.progress-modal-bar-indeterminate { + height: 100%; + width: 40%; + background: linear-gradient(90deg, #4ade80 0%, #22c55e 50%, #16a34a 100%); + border-radius: 6px; + animation: indeterminateSlide 1.5s infinite ease-in-out; + box-shadow: 0 1px 2px rgba(34, 197, 94, 0.3); +} + +@keyframes indeterminateSlide { + 0% { + transform: translateX(-100%); + } + 50% { + transform: translateX(150%); + } + 100% { + transform: translateX(-100%); + } +} + +/* 成功态 */ +.progress-modal-success-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.progress-modal-success-icon { + color: var(--primary, #22c55e); +} + +.progress-modal-success-message { + font-size: 14px; + color: var(--text-secondary, #666666); + text-align: center; + margin: 0; +} + +/* 状态信息 */ +.progress-modal-info { + display: flex; + justify-content: center; + align-items: center; + font-size: 14px; +} + +.progress-modal-status { + color: var(--text-secondary, #666666); +} + +/* 底部操作区 */ +.progress-modal-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + min-height: 24px; +} + +.progress-modal-hint { + font-size: 13px; + color: var(--text-tertiary, #999999); +} + +.progress-modal-loading-dots::after { + content: '...'; + display: inline-block; + width: 1.2em; + text-align: left; + animation: progressModalLoadingDots 1.2s steps(3, end) infinite; +} + +@keyframes progressModalLoadingDots { + 0% { content: ''; } + 33% { content: '.'; } + 66% { content: '..'; } + 100% { content: '...'; } +} + +/* 成功态步骤对勾 */ +/* 错误内容 */ +.progress-modal-error-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.progress-modal-error-icon { + color: var(--error, #ef4444); +} + +.progress-modal-error-message { + font-size: 14px; + color: var(--text-secondary, #666666); + text-align: center; + margin: 0; +} + +.progress-modal-btn { + padding: 10px 32px; + background: var(--primary, #22c55e); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.progress-modal-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.progress-modal-btn:active { + transform: translateY(0); +} + +/* 错误关闭按钮 */ +.progress-modal-btn-error { + background: var(--error, #ef4444); +} + +/* 动画 */ +@keyframes progressModalFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes progressModalSlideUp { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* 暗黑模式适配 */ +[data-theme='dark'] .progress-modal-container { + background: rgba(40, 40, 40, 0.85); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +[data-theme='dark'] .progress-modal-title { + color: var(--text-primary, #ffffff); +} + +[data-theme='dark'] .progress-modal-status { + color: var(--text-secondary, #a0a0a0); +} + + diff --git a/tauri-app/src/components/ProgressModal/ProgressModal.tsx b/tauri-app/src/components/ProgressModal/ProgressModal.tsx new file mode 100644 index 0000000..55945e7 --- /dev/null +++ b/tauri-app/src/components/ProgressModal/ProgressModal.tsx @@ -0,0 +1,159 @@ +/** + * 全局进度弹窗组件 + * ================= + * + * running 状态使用不确定进度条(CSS 条纹动画)+ 阶段文案 + * success / error 状态显示结果图标 + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useProgressStore } from '../../store/progressStore'; +import './ProgressModal.css'; + +export default function ProgressModal() { + const { visible, title, status, phase, errorMessage, hide } = useProgressStore(); + + const isError = phase === 'error'; + const isSuccess = phase === 'success'; + const isRunning = phase === 'running'; + + // 成功按钮延迟出现,避免完成瞬间底部突变 + const [showButton, setShowButton] = useState(false); + useEffect(() => { + if (isSuccess) { + const timer = setTimeout(() => setShowButton(true), 100); + return () => clearTimeout(timer); + } else { + setShowButton(false); + } + }, [isSuccess]); + + const handleClose = useCallback(() => { + if (phase === 'success' || phase === 'error') { + hide(); + } + }, [phase, hide]); + + const handleOverlayClick = useCallback(() => { + if (phase === 'running') return; + handleClose(); + }, [phase, handleClose]); + + if (!visible) { + return null; + } + + return ( +
+
e.stopPropagation()}> + {/* 头部标题 */} +
+ {(title.includes('脚本') || title.includes('文案')) && ( +
+ + + + + + + +
+ )} + {(title.includes('视频') || title.includes('合成')) && ( +
+ + + + + +
+ )} + {(title.includes('字幕') || title.includes('压制')) && ( +
+ + + + + + +
+ )} + {(title.includes('图片') || title.includes('封面')) && ( +
+ + + + + +
+ )} + {(title.includes('形象') || title.includes('克隆')) && ( +
+ + + + +
+ )} +

{title}

+
+ + {/* 主体内容 */} +
+ {isError ? ( +
+
+ + + + + +
+

{errorMessage || '发生错误'}

+
+ ) : isSuccess ? ( +
+
+ + + +
+

{status || '生成完成'}

+
+ ) : ( + <> + {/* 不确定进度条 */} +
+
+
+
+ {status} +
+ + )} +
+ + {/* 底部操作区 */} +
+ {isSuccess && showButton ? ( + + ) : isError ? ( + + ) : ( + + 处理中,请勿退出应用 + + + )} +
+
+
+ ); +} diff --git a/tauri-app/src/components/ShotStats/ShotStats.css b/tauri-app/src/components/ShotStats/ShotStats.css new file mode 100644 index 0000000..9e72e9a --- /dev/null +++ b/tauri-app/src/components/ShotStats/ShotStats.css @@ -0,0 +1,112 @@ +/** + * ShotStats 组件样式 + * ================== + * + * 统一的分镜统计组件样式 + * 使用网格布局展示统计数据 + */ + +.shot-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--spacing-md); +} + +.shot-stats-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); + padding: var(--spacing-md); + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border-light); + box-shadow: var(--shadow-sm); + transition: all var(--transition-fast); +} + +.shot-stats-item:hover { + border-color: var(--primary-light); + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.shot-stats-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); + opacity: 0.8; +} + +.shot-stats-value { + font-size: var(--font-xl); + font-weight: 700; + color: var(--primary); + line-height: 1.2; + text-align: center; +} + +.shot-stats-label { + font-size: var(--font-xs); + color: var(--text-tertiary); + font-weight: 500; + text-align: center; +} + +/* 紧凑型变体 */ +.shot-stats-compact { + display: flex; + gap: var(--spacing-xl); + padding: var(--spacing-md) 0; +} + +.shot-stats-compact .shot-stats-item { + flex-direction: row; + gap: var(--spacing-xs); + padding: 0; + background: transparent; + border: none; + box-shadow: none; +} + +.shot-stats-compact .shot-stats-item:hover { + transform: none; +} + +.shot-stats-compact .shot-stats-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text-secondary); + opacity: 1; +} + +.shot-stats-compact .shot-stats-value { + font-size: var(--font-lg); + color: var(--text-primary); +} + +.shot-stats-compact .shot-stats-label { + font-size: var(--font-xs); +} + +/* 响应式 */ +@media (width <= 640px) { + .shot-stats { + grid-template-columns: repeat(2, 1fr); + } + + .shot-stats-compact { + flex-wrap: wrap; + } + + .shot-stats-compact .shot-stats-item { + flex: 1; + min-width: 100px; + } +} diff --git a/tauri-app/src/components/ShotStats/ShotStats.tsx b/tauri-app/src/components/ShotStats/ShotStats.tsx new file mode 100644 index 0000000..d09bab7 --- /dev/null +++ b/tauri-app/src/components/ShotStats/ShotStats.tsx @@ -0,0 +1,91 @@ +/** + * ShotStats 组件 + * ============== + * + * 显示分镜统计信息(字数、时长、分镜数、空镜数) + * 用于 ScriptCreation 等页面 + */ + +import React from 'react'; +import './ShotStats.css'; + +export interface ShotStatsData { + totalWords: number; + totalDuration: number; + segmentCount: number; + emptyShotCount: number; +} + +interface ShotStatsProps { + stats: ShotStatsData; + className?: string; +} + +/** + * 分镜统计组件 + */ +export const ShotStats: React.FC = ({ stats, className = '' }) => { + const { totalWords, totalDuration, segmentCount /* , emptyShotCount */ } = stats; + + return ( +
+ } value={totalWords} label="总字数" /> + } value={`${totalDuration}s`} label="预计时长" /> + } value={segmentCount} label="分镜数" /> + {/* 空镜功能暂时禁用 */} + {/* } value={emptyShotCount} label="空镜数" /> */} +
+ ); +}; + +// 子组件:单个统计项 +interface StatItemProps { + icon: React.ReactNode; + value: string | number; + label: string; +} + +const StatItem: React.FC = ({ icon, value, label }) => ( +
+
{icon}
+ {value} + {label} +
+); + +// 图标组件 +const WordIcon = () => ( + + + + + + + +); + +const DurationIcon = () => ( + + + + +); + +const SegmentIcon = () => ( + + + + + +); + +/* 空镜功能暂时禁用 */ +// const EmptyShotIcon = () => ( +// +// +// +// +// +// ); + +export default ShotStats; diff --git a/tauri-app/src/components/ShotStats/index.ts b/tauri-app/src/components/ShotStats/index.ts new file mode 100644 index 0000000..23ed7d9 --- /dev/null +++ b/tauri-app/src/components/ShotStats/index.ts @@ -0,0 +1,2 @@ +export { ShotStats, type ShotStatsData } from './ShotStats'; +export { default } from './ShotStats'; diff --git a/tauri-app/src/components/Slider/Slider.css b/tauri-app/src/components/Slider/Slider.css new file mode 100644 index 0000000..9845e91 --- /dev/null +++ b/tauri-app/src/components/Slider/Slider.css @@ -0,0 +1,95 @@ +.slider-group { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.slider-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.slider-label { + font-size: var(--font-sm); + color: var(--text-secondary); + font-weight: 500; +} + +.slider-value { + font-size: var(--font-sm); + color: var(--primary); + font-weight: 600; + min-width: 48px; + text-align: right; +} + +.slider-track-wrapper { + position: relative; +} + +.slider-input { + appearance: none; + appearance: none; + width: 100%; + height: 6px; + background: transparent; + border-radius: var(--radius-sm); + outline: none; + cursor: pointer; + border: none; + padding: 0; +} + +/* WebKit 轨道 - 已选择部分 */ +.slider-input::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + background: linear-gradient( + to right, + var(--primary) 0%, + var(--primary) var(--slider-percent, 50%), + var(--bg-input) var(--slider-percent, 50%), + var(--bg-input) 100% + ); + border-radius: var(--radius-sm); +} + +/* Firefox 轨道 */ +.slider-input::-moz-range-track { + width: 100%; + height: 6px; + background: var(--bg-input); + border-radius: var(--radius-sm); +} + +/* Firefox 已选择部分 */ +.slider-input::-moz-range-progress { + background: var(--primary); + height: 6px; + border-radius: var(--radius-sm); +} + +.slider-input::-webkit-slider-thumb { + appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--bg-card); + border: 2px solid var(--primary); + border-radius: var(--radius-full); + cursor: pointer; + box-shadow: 0 1px 4px rgb(0 0 0 / 15%); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; +} + +.slider-input::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 2px 8px rgb(54 178 106 / 30%); +} + +.slider-input:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 4px var(--primary-light); +} diff --git a/tauri-app/src/components/Toast/Toast.css b/tauri-app/src/components/Toast/Toast.css new file mode 100644 index 0000000..2f6f9cc --- /dev/null +++ b/tauri-app/src/components/Toast/Toast.css @@ -0,0 +1,95 @@ +.toast-container { + position: fixed; + top: 40px; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + pointer-events: none; +} + +.toast-item { + padding: var(--spacing-sm) var(--spacing-3xl); + border-radius: var(--radius-xl); + font-size: var(--font-base); + font-weight: 500; + box-shadow: 0 8px 32px rgb(0 0 0 / 8%); + display: flex; + align-items: center; + position: relative; + pointer-events: auto; + min-width: 260px; + justify-content: center; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-light); + backdrop-filter: blur(20px); + backdrop-filter: blur(20px); + animation: slideDownToast 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: all var(--transition-normal); +} + +@keyframes slideDownToast { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.toast-icon { + position: absolute; + left: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + padding: var(--spacing-xs); +} + +.toast-message { + text-align: center; + flex: 1; +} + +.toast-info { + border: 1px solid rgba(var(--primary-rgb), 0.3); +} + +.toast-info .toast-icon { + background: rgba(var(--primary-rgb), 0.15); + color: var(--primary); +} + +.toast-success { + border: 1px solid rgba(var(--success-rgb), 0.3); +} + +.toast-success .toast-icon { + background: rgba(var(--success-rgb), 0.15); + color: var(--success); +} + +.toast-error { + border: 1px solid rgba(var(--error-rgb), 0.3); +} + +.toast-error .toast-icon { + background: rgba(var(--error-rgb), 0.15); + color: var(--error); +} + +.toast-warning { + border: 1px solid rgba(var(--warning-rgb), 0.3); +} + +.toast-warning .toast-icon { + background: rgba(var(--warning-rgb), 0.15); + color: var(--warning); +} diff --git a/tauri-app/src/components/Toast/ToastContainer.tsx b/tauri-app/src/components/Toast/ToastContainer.tsx new file mode 100644 index 0000000..ec520e1 --- /dev/null +++ b/tauri-app/src/components/Toast/ToastContainer.tsx @@ -0,0 +1,87 @@ +import { useUIStore, type ToastMessage } from '../../store/uiStore'; +import './Toast.css'; + +export default function ToastContainer() { + const { toasts } = useUIStore(); + + return ( +
+ {toasts.map((t: ToastMessage) => ( +
+ {t.type === 'success' && ( +
+ + + + +
+ )} + {t.type === 'error' && ( +
+ + + + + +
+ )} + {t.type === 'info' && ( +
+ + + + + +
+ )} + {t.type === 'warning' && ( +
+ + + + + +
+ )} + {t.message} +
+ ))} +
+ ); +} diff --git a/tauri-app/src/hooks/useAssJsRenderer.ts b/tauri-app/src/hooks/useAssJsRenderer.ts new file mode 100644 index 0000000..9425fc7 --- /dev/null +++ b/tauri-app/src/hooks/useAssJsRenderer.ts @@ -0,0 +1,107 @@ +/** + * ASS.js 字幕渲染 Hook(video 模式) + * =================================== + * + * 基于 assjs 的纯 JavaScript DOM 字幕渲染,无 WASM/Worker 依赖。 + * 字体由浏览器 CSS 处理,自动支持系统字体回退(PingFang SC / Microsoft YaHei)。 + * + * 问题修复: + * - 第一次加载视频,metadata 未就绪就初始化 → 时间计算错误 + * - 修复:等待 loadedmetadata 后再初始化 + */ + +import { useEffect, useRef, useState } from 'react'; +import ASS from 'assjs'; + +interface UseAssJsOptions { + videoRef: React.RefObject; + containerRef: React.RefObject; + assContent: string | null; + enabled: boolean; +} + +export function useAssJsRenderer(options: UseAssJsOptions) { + const instanceRef = useRef | null>(null); + const styleRef = useRef(null); + + // 关键修复:每次 assContent / enabled 变化时,重置就绪状态,重新等待 metadata + const [videoReady, setVideoReady] = useState(false); + + // 每次依赖变化,重置就绪状态 + useEffect(() => { + setVideoReady(false); + + const video = options.videoRef.current; + if (!options.enabled || !video || !options.assContent) { + return; + } + + // 已经有正确 metadata + if (video.readyState >= 1) { + setVideoReady(true); + return; + } + + // 等待 metadata 加载完成 + const onLoadedMetadata = () => { + setVideoReady(true); + }; + video.addEventListener('loadedmetadata', onLoadedMetadata); + return () => { + video.removeEventListener('loadedmetadata', onLoadedMetadata); + }; + }, [options.enabled, options.assContent, options.videoRef]); + + useEffect(() => { + const video = options.videoRef.current; + const container = options.containerRef.current; + + // 总是先清理旧实例 + if (instanceRef.current) { + instanceRef.current.destroy(); + instanceRef.current = null; + } + if (styleRef.current) { + styleRef.current.remove(); + styleRef.current = null; + } + + // 条件不满足 → 不创建新实例 + if (!options.enabled || !video || !container || !options.assContent || !videoReady) { + return; + } + + console.log('[ASS.js] creating instance (video ready)'); + try { + // 注入 CSS:隐藏 assjs 的 :before 阴影伪元素(shadow 已设为 0,不需要这层) + // 注意选择器必须有空格(后代选择器),assjs 的 DOM 结构是 .ASS-dialogue > [data-border-style="1"] + const styleEl = document.createElement('style'); + styleEl.textContent = '.ASS-dialogue [data-border-style="1"]::before{display:none !important}'; + container.appendChild(styleEl); + styleRef.current = styleEl; + + const instance = new ASS(options.assContent, video, { + container, + resampling: 'video_height', + }); + instanceRef.current = instance; + console.log('[ASS.js] instance created'); + } catch (err) { + console.error('[ASS.js] init failed:', err); + } + + return () => { + if (instanceRef.current) { + console.log('[ASS.js] destroying instance'); + instanceRef.current.destroy(); + instanceRef.current = null; + } + if (styleRef.current) { + styleRef.current.remove(); + styleRef.current = null; + } + }; + // assContent 变化时需要重建实例 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.enabled, options.assContent, videoReady]); +} diff --git a/tauri-app/src/hooks/useAvatarCache.ts b/tauri-app/src/hooks/useAvatarCache.ts new file mode 100644 index 0000000..3b4dda8 --- /dev/null +++ b/tauri-app/src/hooks/useAvatarCache.ts @@ -0,0 +1,315 @@ +/** + * 形象本地缓存 Hook + * ================= + * + * 通过 Tauri IPC 调用 Rust 端缓存服务,将远程视频和封面缓存到本地文件系统 + * 前端使用 fs.readFile + Blob URL 加载本地文件,绕过 asset protocol 404 问题 + */ + +import { invoke } from '@tauri-apps/api/core'; +import { readFile } from '@tauri-apps/plugin-fs'; +import { useState, useEffect, useRef } from 'react'; + +/** 缓存查询结果 */ +export interface CacheQueryResult { + cached: boolean; + local_video_path: string | null; + local_poster_path: string | null; +} + +/** 缓存保存结果 */ +export interface CacheSaveResult { + success: boolean; + localPath: string | null; + message: string | null; +} + +/** 删除结果 */ +export interface CacheDeleteResult { + success: boolean; + message: string | null; +} + +/** + * 查询头像缓存状态 + */ +export async function queryAvatarCache(avatarId: string): Promise { + const result = await invoke('query_avatar_cache', { avatarId }); + return result; +} + +/** + * 下载远程视频并缓存到本地 + */ +export async function cacheAvatarVideo( + avatarId: string, + remoteUrl: string +): Promise { + const result = await invoke('cache_avatar_video', { + avatarId, + remoteUrl, + }); + return result; +} + +/** + * 保存封面(base64)到缓存 + */ +export async function saveAvatarPoster( + avatarId: string, + base64Data: string +): Promise { + const result = await invoke('save_avatar_poster', { + avatarId, + base64Data, + }); + return result; +} + +/** + * 删除头像缓存 + */ +export async function deleteAvatarCache(avatarId: string): Promise { + const result = await invoke('delete_avatar_cache', { avatarId }); + return result; +} + +/** 缓存统计信息 */ +export interface CacheStats { + count: number; + totalSizeMb: number; + maxSizeMb: number; + usagePercent: number; + thresholdPercent: number; +} + +/** + * 获取缓存统计信息 + */ +export async function getCacheStats(): Promise { + const result = await invoke<{ + code: number; + data: { + count: number; + total_size_mb: number; + max_size_mb: number; + usage_percent: number; + threshold_percent: number; + }; + }>('get_cache_stats'); + return { + count: result.data.count, + totalSizeMb: result.data.total_size_mb, + maxSizeMb: result.data.max_size_mb, + usagePercent: result.data.usage_percent, + thresholdPercent: result.data.threshold_percent, + }; +} + +/** + * Hook: 获取带缓存的视频URL + * - 优先返回本地缓存的 Blob URL + * - 没有缓存则返回远程URL,并在后台下载缓存 + */ +export function useCachedVideoUrl( + avatarId: string, + remoteUrl: string | undefined +): { + videoUrl: string | undefined; + isCached: boolean; + isCaching: boolean; +} { + const [videoUrl, setVideoUrl] = useState(remoteUrl); + const [isCached, setIsCached] = useState(false); + const [isCaching, setIsCaching] = useState(false); + const blobUrlRef = useRef(null); + + useEffect(() => { + if (!avatarId || !remoteUrl) { + // 清理旧 Blob URL + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + setVideoUrl(remoteUrl); + setIsCached(false); + return; + } + + let canceled = false; + + async function checkCache() { + try { + const result = await queryAvatarCache(avatarId); + if (canceled) return; + + if (result.cached && result.local_video_path) { + // 有缓存,读取本地文件创建 Blob URL + const data = await readFile(result.local_video_path); + if (canceled) return; + + const blob = new Blob([data], { type: 'video/mp4' }); + const url = URL.createObjectURL(blob); + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + } + blobUrlRef.current = url; + + setVideoUrl(url); + setIsCached(true); + setIsCaching(false); + } else if (remoteUrl) { + // 没有缓存,开始后台下载 + setIsCaching(true); + const cacheResult = await cacheAvatarVideo(avatarId, remoteUrl); + if (canceled) return; + + if (cacheResult.success && cacheResult.localPath) { + const data = await readFile(cacheResult.localPath); + if (canceled) return; + + const blob = new Blob([data], { type: 'video/mp4' }); + const url = URL.createObjectURL(blob); + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + } + blobUrlRef.current = url; + + setVideoUrl(url); + setIsCached(true); + } + setIsCaching(false); + } + } catch (e) { + console.warn('[useAvatarCache] Cache check failed:', e); + if (!canceled) { + // 缓存失败,降级使用远程URL + setVideoUrl(remoteUrl); + setIsCaching(false); + } + } + } + + checkCache(); + + return () => { + canceled = true; + }; + }, [avatarId, remoteUrl]); + + // 组件卸载时释放 Blob URL + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return { + videoUrl, + isCached, + isCaching, + }; +} + +/** + * Hook: 获取带缓存的封面URL + */ +export function useCachedPosterUrl( + avatarId: string, + generatePoster: () => Promise +): { + posterUrl: string; + isLoaded: boolean; + loadPoster: () => Promise; +} { + const [posterUrl, setPosterUrl] = useState(''); + const [isLoaded, setIsLoaded] = useState(false); + const blobUrlRef = useRef(null); + + useEffect(() => { + let canceled = false; + + async function checkAndGenerate() { + try { + // 先检查是否已有缓存的海报 + const result = await queryAvatarCache(avatarId); + if (canceled) return; + + if (result.cached && result.local_poster_path) { + // 有缓存,读取本地文件创建 Blob URL + const data = await readFile(result.local_poster_path); + if (canceled) return; + + const blob = new Blob([data], { type: 'image/jpeg' }); + const url = URL.createObjectURL(blob); + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + } + blobUrlRef.current = url; + + setPosterUrl(url); + setIsLoaded(true); + return; + } + + // 没有缓存,生成新海报 + const generated = await generatePoster(); + if (canceled) return; + + if (generated) { + setPosterUrl(generated); + setIsLoaded(true); + + // 保存到缓存(后台,不等待) + if (generated.startsWith('data:')) { + saveAvatarPoster(avatarId, generated).catch(e => { + console.warn('[useAvatarCache] Failed to save poster to cache:', e); + }); + } + } else { + // 生成失败,使用占位符 + setPosterUrl(''); + setIsLoaded(true); + } + } catch (e) { + console.warn('[useAvatarCache] Poster cache check failed:', e); + } + } + + checkAndGenerate(); + + return () => { + canceled = true; + }; + }, [avatarId, generatePoster]); + + // 组件卸载时释放 Blob URL + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return { + posterUrl, + isLoaded, + loadPoster: async () => { + const generated = await generatePoster(); + setPosterUrl(generated); + setIsLoaded(true); + if (generated.startsWith('data:')) { + try { + await saveAvatarPoster(avatarId, generated); + } catch (e) { + console.warn('[useAvatarCache] Failed to save poster to cache:', e); + } + } + }, + }; +} diff --git a/tauri-app/src/hooks/useAvatarLibrary.ts b/tauri-app/src/hooks/useAvatarLibrary.ts new file mode 100644 index 0000000..4c9ecca --- /dev/null +++ b/tauri-app/src/hooks/useAvatarLibrary.ts @@ -0,0 +1,123 @@ +/** + * Avatar Library Hook - SWR + * ========================= + * + * 提供自动缓存、重验证、错误重试的形象库数据获取 + * + * 设计策略:克隆形象完全本地存储 + * - 克隆形象仅保存在 Tauri 本地文件存储,不再同步云端 + * - 任务完成后自动从 taskStore 同步到本地形象库 + * - 预设形象仍来自后端 API + * + * 形象库组成: + * - 预设形象:来自后端 API + * - 克隆形象:来自 Tauri 本地文件存储 + */ + +import useSWR from 'swr'; +import { avatarApi, type AvatarItem } from '../api/modules/avatar'; +import { + loadClonedAvatarsFromLocal, + syncCompletedAvatarClonesFromTaskStore, +} from '../utils/avatarStorage'; + +const AVATAR_LIBRARY_KEY = 'avatar-library'; + +/** + * 合并预设形象和克隆形象 + * - 使用 Map 去重,保证 id 唯一 + */ +function mergeAvatarLibrary( + presetAvatars: AvatarItem[], + clonedAvatars: AvatarItem[] +): AvatarItem[] { + // 使用 Map 去重,以 id 为准 + const avatarMap = new Map(); + + // 先加预设 + presetAvatars.forEach(avatar => { + if (avatar.id) { + avatarMap.set(String(avatar.id), avatar); + } + }); + + // 再加克隆形象,覆盖冲突 + clonedAvatars.forEach(avatar => { + if (avatar.id) { + avatarMap.set(String(avatar.id), avatar); + } + }); + + return Array.from(avatarMap.values()); +} + +/** + * 获取形象库列表 + * - 优先使用本地文件存储 + * - 本地为空时从后端获取云端备份并保存到本地 + * - 窗口聚焦时自动重新验证(如果本地为空,重新拉取) + */ +export function useAvatarLibrary() { + const { data, error, isLoading, mutate } = useSWR( + AVATAR_LIBRARY_KEY, + async () => { + // 1. 先将已完成的任务结果同步到本地形象库 + await syncCompletedAvatarClonesFromTaskStore(); + + // 2. 加载本地克隆形象(Tauri 文件存储) + const localClonedAvatars = await loadClonedAvatarsFromLocal(); + + // 3. 获取预设形象(始终从后端获取) + const presetAvatars = await avatarApi.getPresetLibrary(); + + // 4. 合并数据(克隆形象完全本地存储,不再走云端备份) + return mergeAvatarLibrary(presetAvatars, localClonedAvatars); + }, + { + revalidateOnFocus: true, + revalidateOnReconnect: true, + dedupingInterval: 5000, + errorRetryCount: 3, + errorRetryInterval: 1000, + } + ); + + return { + avatarLibrary: data ?? [], + isLoading, + error, + mutate, + }; +} + +/** + * 获取指定形象信息 + */ +export function useAvatarInfo(avatarId: string | undefined) { + const { avatarLibrary, isLoading } = useAvatarLibrary(); + + return { + avatarInfo: avatarId ? avatarLibrary.find(a => a.id === avatarId) : undefined, + isLoading, + }; +} + +/** + * 按分类筛选形象 + * 克隆形象通过 elementId > 0 识别;预设形象暂无分类字段时通过是否有 elementId 辅助判断 + */ +export function useAvatarsByCategory(category: 'preset' | 'clone') { + const { avatarLibrary, isLoading, error, mutate } = useAvatarLibrary(); + + const filtered = + category === 'clone' + ? avatarLibrary.filter(a => a.elementId && a.elementId > 0) + : avatarLibrary.filter(a => !a.elementId); + + return { + avatars: filtered, + isLoading, + error, + mutate, + }; +} diff --git a/tauri-app/src/hooks/useLocalImage.ts b/tauri-app/src/hooks/useLocalImage.ts new file mode 100644 index 0000000..7d95bf0 --- /dev/null +++ b/tauri-app/src/hooks/useLocalImage.ts @@ -0,0 +1,96 @@ +/** + * 本地图片加载 Hook + * ================= + * + * 使用 Tauri fs API 读取本地图片文件,创建 Blob URL 供 img 标签使用 + * 绕过 asset:// protocol 的权限问题 + */ + +import { useState, useEffect, useRef } from 'react'; +import { readFile } from '@tauri-apps/plugin-fs'; +import { homeDir } from '@tauri-apps/api/path'; + +interface UseLocalImageResult { + imageUrl: string | undefined; + isLoading: boolean; + error: string | null; +} + +/** + * 加载本地图片文件,返回 Blob URL + * + * @param filePath 本地文件绝对路径 + * @returns Blob URL 或 undefined + */ +export function useLocalImage(filePath: string | undefined): UseLocalImageResult { + const [imageUrl, setImageUrl] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const prevUrlRef = useRef(undefined); + + useEffect(() => { + if (prevUrlRef.current && prevUrlRef.current.startsWith('blob:')) { + URL.revokeObjectURL(prevUrlRef.current); + } + + if (!filePath) { + setImageUrl(undefined); + setIsLoading(false); + setError(null); + return; + } + + if (filePath.startsWith('http')) { + setImageUrl(filePath); + setIsLoading(false); + prevUrlRef.current = filePath; + return; + } + + let canceled = false; + + async function resolveHostPath(dockerPath: string): Promise { + if (dockerPath.startsWith('/root/')) { + const home = await homeDir(); + return dockerPath.replace(/^\/root/, home); + } + return dockerPath; + } + + async function loadImage() { + setIsLoading(true); + setError(null); + + try { + const resolvedPath = await resolveHostPath(filePath!); + const data = await readFile(resolvedPath); + + if (canceled) return; + + const blob = new Blob([data], { type: 'image/jpeg' }); + const url = URL.createObjectURL(blob); + + if (!canceled) { + setImageUrl(url); + prevUrlRef.current = url; + } + } catch (err: any) { + if (!canceled) { + setError(err?.message || '加载图片失败'); + } + } finally { + if (!canceled) { + setIsLoading(false); + } + } + } + + loadImage(); + + return () => { + canceled = true; + }; + }, [filePath]); + + return { imageUrl, isLoading, error }; +} diff --git a/tauri-app/src/hooks/useLocalVideo.ts b/tauri-app/src/hooks/useLocalVideo.ts new file mode 100644 index 0000000..c7558f9 --- /dev/null +++ b/tauri-app/src/hooks/useLocalVideo.ts @@ -0,0 +1,118 @@ +/** + * 本地视频加载 Hook + * ================= + * + * 使用 Tauri fs.readFile 读取本地视频文件为 Uint8Array, + * 然后创建 Blob URL 供 video 标签使用。 + * 绕过 convertFileSrc/asset protocol 在 macOS 上的 404 问题。 + */ + +import { useState, useEffect, useRef } from 'react'; +import { readFile } from '@tauri-apps/plugin-fs'; +import { homeDir } from '@tauri-apps/api/path'; + +interface UseLocalVideoResult { + videoUrl: string | undefined; + isLoading: boolean; + error: string | null; +} + +/** + * 将 Docker 容器路径转换为宿主机路径 + * Docker 内 /root 映射到宿主机 home 目录 + */ +async function resolveHostPath(dockerPath: string): Promise { + if (dockerPath.startsWith('/root/')) { + const home = await homeDir(); + return dockerPath.replace(/^\/root/, home || '/Users'); + } + return dockerPath; +} + +/** + * 加载本地视频文件,返回 Blob URL + * + * @param filePath 本地文件绝对路径(如 /Users/.../scene_1.mp4) + * @returns Blob URL 或 undefined + */ +export function useLocalVideo(filePath: string | undefined): UseLocalVideoResult { + const [videoUrl, setVideoUrl] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const blobUrlRef = useRef(null); + + useEffect(() => { + // 清理旧的 Blob URL + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + + if (!filePath) { + setVideoUrl(undefined); + setIsLoading(false); + setError(null); + return; + } + + // 远程 URL 直接使用 + if (filePath.startsWith('http')) { + setVideoUrl(filePath); + setIsLoading(false); + setError(null); + return; + } + + let canceled = false; + + async function load() { + if (!filePath) return; + setIsLoading(true); + setError(null); + + try { + const hostPath = await resolveHostPath(filePath); + if (canceled) return; + + const data = await readFile(hostPath); + if (canceled) return; + + const blob = new Blob([data], { type: 'video/mp4' }); + const url = URL.createObjectURL(blob); + blobUrlRef.current = url; + + if (!canceled) { + setVideoUrl(url); + } + } catch (e) { + if (!canceled) { + console.error('[useLocalVideo] Failed to load video:', e); + setError(e instanceof Error ? e.message : '加载失败'); + setVideoUrl(undefined); + } + } finally { + if (!canceled) { + setIsLoading(false); + } + } + } + + load(); + + return () => { + canceled = true; + }; + }, [filePath]); + + // 组件卸载时释放 Blob URL + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return { videoUrl, isLoading, error }; +} diff --git a/tauri-app/src/hooks/useModelHealth.ts b/tauri-app/src/hooks/useModelHealth.ts new file mode 100644 index 0000000..d2aba41 --- /dev/null +++ b/tauri-app/src/hooks/useModelHealth.ts @@ -0,0 +1,37 @@ +/** + * Model Health Hook - SWR + * ======================= + * + * 模型健康状态监控 + */ + +import useSWR from 'swr'; +import { scriptApi, ModelHealthResponse } from '../api/modules/script'; + +const MODEL_HEALTH_KEY = 'model-health'; + +/** + * 获取模型健康状态 + * - 每 30 秒自动刷新 + * - 适合在设置页面展示 + */ +export function useModelHealth() { + const { data, error, isLoading, mutate } = useSWR( + MODEL_HEALTH_KEY, + () => scriptApi.checkModelHealth(), + { + refreshInterval: 30000, // 30 秒自动刷新 + revalidateOnFocus: false, + errorRetryCount: 2, + } + ); + + return { + health: data, + isLoading, + error, + mutate, + isHealthy: data?.status === 'healthy', + availableModels: data?.availableModels ?? 0, + }; +} diff --git a/tauri-app/src/hooks/usePerformanceMonitor.ts b/tauri-app/src/hooks/usePerformanceMonitor.ts new file mode 100644 index 0000000..926bffa --- /dev/null +++ b/tauri-app/src/hooks/usePerformanceMonitor.ts @@ -0,0 +1,86 @@ +/** + * Performance Monitor Hook + * ======================== + * + * 监控组件渲染性能,帮助发现性能问题 + * 仅在开发环境生效 + */ + +import { useEffect, useRef } from 'react'; + +interface PerformanceMetrics { + renderCount: number; + lastRenderTime: number; + averageRenderTime: number; +} + +/** + * 监控组件渲染性能 + * + * 使用方式: + * function MyComponent() { + * usePerformanceMonitor('MyComponent'); + * return
...
; + * } + */ +export function usePerformanceMonitor(componentName: string) { + // 仅在开发环境监控 + if (import.meta.env.PROD) { + return; + } + + const metricsRef = useRef({ + renderCount: 0, + lastRenderTime: 0, + averageRenderTime: 0, + }); + + const startTimeRef = useRef(0); + + useEffect(() => { + startTimeRef.current = performance.now(); + }); + + useEffect(() => { + const endTime = performance.now(); + const renderTime = endTime - startTimeRef.current; + + const metrics = metricsRef.current; + metrics.renderCount++; + metrics.lastRenderTime = renderTime; + metrics.averageRenderTime = + (metrics.averageRenderTime * (metrics.renderCount - 1) + renderTime) / metrics.renderCount; + + // 慢渲染警告(超过 16ms = 1帧) + if (renderTime > 16) { + console.warn( + `[Performance] ${componentName} rendered slowly: ${renderTime.toFixed(2)}ms ` + + `(avg: ${metrics.averageRenderTime.toFixed(2)}ms, count: ${metrics.renderCount})` + ); + } + }); +} + +/** + * 测量函数执行时间 + */ +export function measurePerformance any>(fn: T, name: string): T { + return function (...args: Parameters): ReturnType { + const start = performance.now(); + const result = fn(...args); + const end = performance.now(); + + console.log(`[Performance] ${name} took ${(end - start).toFixed(2)}ms`); + + return result; + } as T; +} + +/** + * 创建性能标记(用于 React DevTools Profiler) + */ +export function markRenderPhase(phase: string) { + if (import.meta.env.DEV && window.performance) { + performance.mark(`react-${phase}`); + } +} diff --git a/tauri-app/src/hooks/useSubtitleAlignment.ts b/tauri-app/src/hooks/useSubtitleAlignment.ts new file mode 100644 index 0000000..c06edc7 --- /dev/null +++ b/tauri-app/src/hooks/useSubtitleAlignment.ts @@ -0,0 +1,390 @@ +/** + * 字幕打轴 Hook(Async Engine 版) + * ================================ + * + * 管理字幕打轴流程,调用后端 Async Engine 统一任务 API。 + * 每个 shot 提交一个独立的 subtitle 任务(mode: auto_align)。 + * 打轴完成后,可以根据时间轴预览不同样式的字幕效果。 + * 打轴结果自动持久化到项目数据。 + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useTask } from './useTask'; +import { useProgressStore } from '../store/progressStore'; +import type { AlignmentResult as StoreAlignmentResult } from '../api/types'; + +/** 字幕片段 */ +export interface Utterance { + text: string; + startTime: number; // 毫秒 + endTime: number; // 毫秒 +} + +/** 打轴状态 */ +export type AlignmentStatus = 'pending' | 'aligning' | 'completed' | 'failed'; + +/** 打轴结果 */ +export interface AlignmentResult { + shotId: number; + status: AlignmentStatus; + utterances?: Utterance[]; + duration?: number; + errorMessage?: string; +} + +/** 打轴请求参数 */ +interface AlignParams { + videoUrl: string; + audioText: string; +} + +/** 分镜数据(用于初始化恢复) */ +interface Segment { + id: number; + alignmentResult?: StoreAlignmentResult; +} + +/** 更新分镜数据的回调 */ +type UpdateSegmentFn = (id: number, data: Partial) => void; + +/** + * 字幕打轴 Hook + * + * @param segments 当前项目分镜列表(用于初始化恢复打轴结果) + * @param updateSegment 更新分镜数据的函数(用于持久化打轴结果) + */ +export function useSubtitleAlignment( + segments: Segment[] = [], + updateSegment?: UpdateSegmentFn +) { + const { submit } = useTask(); + const [results, setResults] = useState>({}); + const [isAligning, setIsAligning] = useState(false); + const [progress, setProgress] = useState({ + current: 0, + total: 0, + }); + + // 跟踪正在进行的任务数量 + const pendingCountRef = useRef(0); + // 跟踪已完成/失败的任务数量(用于弹窗进度) + const completedCountRef = useRef(0); + const failedCountRef = useRef(0); + + // 使用 ref 标记是否已初始化,避免依赖数组大小变化问题 + const hasInitializedRef = useRef(false); + const prevSegmentsLengthRef = useRef(0); + + /** + * 从 segments 恢复打轴结果(页面加载时调用) + */ + useEffect(() => { + const currentLength = segments.length; + const prevLength = prevSegmentsLengthRef.current; + + // 只有当 segments 从 0 变为有数据时才初始化 + if (currentLength > 0 && prevLength === 0 && !hasInitializedRef.current) { + hasInitializedRef.current = true; + + const restoredResults: Record = {}; + + segments.forEach(segment => { + if (segment.alignmentResult) { + restoredResults[segment.id] = { + shotId: segment.id, + status: segment.alignmentResult.status, + utterances: segment.alignmentResult.utterances?.map(u => ({ + text: u.text, + startTime: u.start_time, + endTime: u.end_time, + })), + duration: segment.alignmentResult.duration, + errorMessage: segment.alignmentResult.errorMessage, + }; + } + }); + + // 从持久化数据恢复内存状态(初始化时同步 setState 是合理的) + // eslint-disable-next-line react-hooks/set-state-in-effect + setResults(restoredResults); + } + + prevSegmentsLengthRef.current = currentLength; + }, [segments]); + + /** + * 保存打轴结果到 store + */ + const saveResultToStore = useCallback((shotId: number, result: AlignmentResult) => { + if (!updateSegment) { + return; + } + + const alignmentResult = { + status: result.status, + utterances: result.utterances?.map(u => ({ + text: u.text, + start_time: u.startTime, + end_time: u.endTime, + })), + duration: result.duration, + errorMessage: result.errorMessage, + }; + updateSegment(shotId, { alignmentResult }); + }, [updateSegment]); + + /** + * 单个分镜打轴(通过 Async Engine 提交异步任务) + */ + const alignSingle = useCallback(async ( + shotId: number, + params: AlignParams + ): Promise => { + if (!params.videoUrl) { + return null; + } + if (!params.audioText) { + return null; + } + + // 更新状态为打轴中 + setResults(prev => ({ ...prev, [shotId]: { shotId, status: 'aligning' } })); + + // 必须在 submit() 之前递增计数器。 + // submit() 内部出错时会同步调用 onError 回调(在返回 null 之前)。 + // 如果在这里不先递增,onError 中的递减会导致计数器变为负数, + // 进而使 alignBatch 的等待循环提前结束(isAligning 在任务仍在运行时变为 false)。 + pendingCountRef.current += 1; + + try { + const taskId = await submit( + 'subtitle', + { + videoPath: params.videoUrl, + audioText: params.audioText, + mode: 'auto_align', + language: 'zh', + }, + { + showProgress: false, + callbacks: { + onComplete: (result: unknown) => { + const r = result as { + utterances?: Array<{ text: string; startTime: number; endTime: number }>; + duration?: number; + } | undefined; + + const alignmentResult: AlignmentResult = { + shotId, + status: 'completed', + utterances: r?.utterances?.map(u => ({ + text: u.text, + startTime: u.startTime, + endTime: u.endTime, + })), + duration: r?.duration, + }; + setResults(prev => ({ ...prev, [shotId]: alignmentResult })); + saveResultToStore(shotId, alignmentResult); + + completedCountRef.current += 1; + const total = completedCountRef.current + failedCountRef.current + pendingCountRef.current - 1; + pendingCountRef.current -= 1; + if (pendingCountRef.current <= 0) { + pendingCountRef.current = 0; + setIsAligning(false); + } + + // 更新弹窗进度 + const done = completedCountRef.current + failedCountRef.current; + useProgressStore.getState().update( + `正在打轴... (${done}/${total > 0 ? total : done})` + ); + }, + onError: (error: string) => { + const alignmentResult: AlignmentResult = { + shotId, + status: 'failed', + errorMessage: error, + }; + setResults(prev => ({ ...prev, [shotId]: alignmentResult })); + saveResultToStore(shotId, alignmentResult); + + failedCountRef.current += 1; + const total = completedCountRef.current + failedCountRef.current + pendingCountRef.current - 1; + pendingCountRef.current -= 1; + if (pendingCountRef.current <= 0) { + pendingCountRef.current = 0; + setIsAligning(false); + } + + // 更新弹窗进度 + const done = completedCountRef.current + failedCountRef.current; + useProgressStore.getState().update( + `正在打轴... (${done}/${total > 0 ? total : done})` + ); + }, + }, + } + ); + + if (!taskId) { + // submit() 已内部处理错误并调用了 onError(递减过计数器), + // 计数器已恢复到正确值,无需额外操作。 + return null; + } + + return taskId; + } catch (error) { + // alignSingle 自身抛出的异常(理论上极少,因为 submit 已内部捕获) + failedCountRef.current += 1; + pendingCountRef.current -= 1; + if (pendingCountRef.current <= 0) { + pendingCountRef.current = 0; + setIsAligning(false); + } + + // 更新弹窗进度 + const done = completedCountRef.current + failedCountRef.current; + const total = done + pendingCountRef.current; + useProgressStore.getState().update( + `正在打轴... (${done}/${total > 0 ? total : done})` + ); + + const errorMessage = error instanceof Error ? error.message : '打轴失败'; + const result: AlignmentResult = { + shotId, + status: 'failed', + errorMessage, + }; + setResults(prev => ({ ...prev, [shotId]: result })); + saveResultToStore(shotId, result); + return null; + } + }, [submit, saveResultToStore]); + + /** + * 批量打轴 + * + * 并行提交所有 shot 的异步任务,等待所有任务完成后返回。 + * 调用方可以通过 await alignBatch() 确保所有打轴真正完成后再执行后续操作。 + */ + const alignBatch = useCallback(async ( + shots: Array<{ id: number; videoUrl?: string; audioText?: string }> + ) => { + if (shots.length === 0) { + return; + } + + const validShots = shots.filter(s => s.videoUrl && s.audioText); + if (validShots.length === 0) { + return; + } + + setIsAligning(true); + setProgress({ current: 0, total: validShots.length }); + pendingCountRef.current = 0; + completedCountRef.current = 0; + failedCountRef.current = 0; + + // 显示统一进度弹窗 + const total = validShots.length; + useProgressStore.getState().show('字幕打轴'); + + // 并行提交所有任务 + const submitPromises = validShots.map(async (shot, index) => { + const taskId = await alignSingle(shot.id, { + videoUrl: shot.videoUrl!, + audioText: shot.audioText!, + }); + setProgress({ current: index + 1, total: validShots.length }); + return taskId; + }); + + await Promise.all(submitPromises); + + // 等待所有任务真正完成(或失败) + if (pendingCountRef.current > 0) { + await new Promise((resolve) => { + const startTime = Date.now(); + const MAX_WAIT_MS = 5 * 60 * 1000; // 5 分钟安全超时 + const check = () => { + if (pendingCountRef.current <= 0) { + resolve(); + } else if (Date.now() - startTime > MAX_WAIT_MS) { + console.warn('[useSubtitleAlignment] alignBatch wait timeout'); + pendingCountRef.current = 0; + resolve(); + } else { + setTimeout(check, 300); + } + }; + check(); + }); + } + + // 弹窗显示最终结果 + const completed = completedCountRef.current; + const failed = failedCountRef.current; + if (failed > 0) { + useProgressStore.getState().error( + `打轴完成:${completed}/${total} 成功,${failed} 个失败` + ); + } else { + useProgressStore.getState().success('打轴完成'); + } + + setIsAligning(false); + setProgress({ current: 0, total: 0 }); + }, [alignSingle]); + + /** + * 获取当前时间对应的字幕文本 + */ + const getSubtitleAtTime = useCallback((shotId: number, currentTimeMs: number): string | null => { + const result = results[shotId]; + if (!result?.utterances) { + return null; + } + + const utterance = result.utterances.find( + u => currentTimeMs >= u.startTime && currentTimeMs <= u.endTime + ); + return utterance?.text || null; + }, [results]); + + /** + * 重置指定分镜的打轴结果 + */ + const resetShot = useCallback((shotId: number) => { + const result: AlignmentResult = { + shotId, + status: 'pending', + }; + setResults(prev => ({ ...prev, [shotId]: result })); + saveResultToStore(shotId, result); + }, [saveResultToStore]); + + /** + * 重置所有打轴结果 + */ + const resetAll = useCallback(() => { + setResults({}); + setProgress({ current: 0, total: 0 }); + // 注意:重置所有需要遍历所有 segments 清除 store 中的数据 + // 这里只清理内存状态,store 中的数据由调用方处理 + }, []); + + return { + results, + isAligning, + progress, + alignSingle, + alignBatch, + getSubtitleAtTime, + resetShot, + resetAll, + }; +} + +export default useSubtitleAlignment; diff --git a/tauri-app/src/hooks/useTask.ts b/tauri-app/src/hooks/useTask.ts new file mode 100644 index 0000000..b3afdfb --- /dev/null +++ b/tauri-app/src/hooks/useTask.ts @@ -0,0 +1,256 @@ +/** + * 统一任务管理 Hook + * ================= + * + * 封装任务提交、轮询、状态管理 + * 与 progressStore 联动显示进度弹窗 + */ + +import { useCallback, useRef, useEffect } from 'react'; +import { client } from '../api/client'; +import { useProgressStore } from '../store/progressStore'; +import { useTaskStore, TaskType, TaskStatus } from '../store/taskStore'; +import { listTasks } from '../api/modules/task'; +import { toast } from '../store/uiStore'; + +// 稳定的 store getter,避免 useCallback 依赖数组频繁重建 +const getProgress = () => useProgressStore.getState(); +const getTaskStore = () => useTaskStore.getState(); + +export interface TaskCallbacks { + onProgress?: (status: string, progress: number, message: string) => void; + onComplete?: (result: unknown) => void; + onError?: (error: string) => void; +} + +interface SubmitOptions { + projectId?: string; + showProgress?: boolean; + callbacks?: TaskCallbacks; +} + +// 轮询间隔配置 +const POLL_CONFIG = { + initialInterval: 1000, // 初始1秒 + maxInterval: 3000, // 封顶3秒 + backoffMultiplier: 1.5, +}; + +// 任务类型对应的显示标题 +const TASK_TITLES: Record = { + video: '视频生成', + image: '图片生成', + script: '脚本生成', + subtitle: '字幕对齐', + copy: '文案提取', + avatar_clone: '形象克隆', +}; + +export function useTask() { + const abortRef = useRef>(new Set()); + const intervalRef = useRef>(new Map()); + + // 清理函数 + useEffect(() => { + const abortSet = abortRef.current; + return () => { + abortSet.clear(); + }; + }, []); + + /** + * 开始轮询任务状态 + */ + const startPolling = useCallback( + (taskId: string, callbacks?: TaskCallbacks) => { + abortRef.current.delete(taskId); + intervalRef.current.set(taskId, POLL_CONFIG.initialInterval); + + const poll = async () => { + if (abortRef.current.has(taskId)) { + return; + } + + try { + const taskPath = `/tasks/${taskId}`; + const task = await client.get<{ + taskId: string; + type: TaskType; + status: TaskStatus; + progress: number; + message: string; + result?: unknown; + error?: string; + }>(taskPath); + + // 更新本地状态 + getTaskStore().updateTask(taskId, { + status: task.status, + progress: task.progress, + message: task.message, + }); + + // 更新进度弹窗 + const progressState = getProgress(); + if (progressState.visible) { + progressState.update(task.message); + } + + // 调用进度回调(失败状态优先用 error 字段,避免 message 还是旧文案) + const progressMessage = task.status === 'failed' && task.error ? task.error : task.message; + callbacks?.onProgress?.(task.status, task.progress, progressMessage); + + // 处理完成状态 + if (task.status === 'completed') { + getTaskStore().completeTask(taskId, task.result); + progressState.success('任务完成'); + callbacks?.onComplete?.(task.result); + intervalRef.current.delete(taskId); + return; + } + + // 处理失败状态 + if (task.status === 'failed') { + getTaskStore().failTask(taskId, task.error || '任务失败'); + progressState.error(task.error || '任务失败'); + callbacks?.onError?.(task.error || '任务失败'); + intervalRef.current.delete(taskId); + return; + } + + // 继续轮询 + const currentInterval = intervalRef.current.get(taskId) || POLL_CONFIG.initialInterval; + const nextInterval = Math.min( + currentInterval * POLL_CONFIG.backoffMultiplier, + POLL_CONFIG.maxInterval + ); + intervalRef.current.set(taskId, nextInterval); + + setTimeout(poll, nextInterval); + } catch { + setTimeout(poll, 3000); + } + }; + + poll(); + }, + [] + ); + + /** + * 提交任务 + */ + const submit = useCallback( + async ( + type: TaskType, + params: Record, + options: SubmitOptions = {} + ): Promise => { + const { projectId, showProgress = true, callbacks } = options; + + try { + const response = await client.post<{ taskId: string; status: string }>( + `/tasks/${type}`, + { + projectId, + params, + } + ); + + const taskId = response.taskId; + + getTaskStore().addTask({ + id: taskId, + type, + projectId, + status: 'pending', + progress: 0, + message: '任务已创建', + }); + + if (showProgress) { + getProgress().show(TASK_TITLES[type]); + } + + startPolling(taskId, callbacks); + + return taskId; + } catch (error) { + const message = error instanceof Error ? error.message : '创建任务失败'; + toast.error(message); + callbacks?.onError?.(message); + return null; + } + }, + [startPolling] + ); + + /** + * 从后端查询 running 任务并恢复轮询 + */ + const fetchRunningTasks = useCallback( + async (projectId?: string, callbacks?: TaskCallbacks) => { + try { + const tasks = await listTasks(projectId); + const runningTasks = tasks.filter( + (t) => t.status === 'running' || t.status === 'pending' + ); + for (const task of runningTasks) { + if (intervalRef.current.has(task.taskId)) continue; + + getTaskStore().addTask({ + id: task.taskId, + type: task.type, + projectId, + status: task.status, + progress: task.progress, + message: task.message, + }); + + startPolling(task.taskId, callbacks); + } + return runningTasks; + } catch (err) { + console.error('[useTask] Failed to fetch running tasks:', err); + return []; + } + }, + [startPolling] + ); + + /** + * 取消任务(仅停止轮询,后端任务继续运行) + */ + const cancel = useCallback((taskId: string) => { + abortRef.current.add(taskId); + intervalRef.current.delete(taskId); + }, []); + + /** + * 获取任务状态 + */ + const getTask = useCallback( + (taskId: string) => { + return getTaskStore().tasks.find((t) => t.id === taskId); + }, + [] + ); + + /** + * 检查是否有进行中的任务 + */ + const hasRunningTasks = useCallback(() => { + return getTaskStore().getRunningTasks().length > 0; + }, []); + + return { + submit, + startPolling, + fetchRunningTasks, + restoreTask: startPolling, + cancel, + getTask, + hasRunningTasks, + runningTasks: getTaskStore().getRunningTasks(), + }; +} diff --git a/tauri-app/src/hooks/useVideoGeneration.ts b/tauri-app/src/hooks/useVideoGeneration.ts new file mode 100644 index 0000000..7072090 --- /dev/null +++ b/tauri-app/src/hooks/useVideoGeneration.ts @@ -0,0 +1,319 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useTask } from './useTask'; +import { useProgressStore } from '../store/progressStore'; + +export interface GenerationResult { + projectId: string; + completed: number; + failed: number; + total: number; + shots: Array<{ + shotId: string; + type: string; + status: string; + taskId: string | null; + videoUrl: string | null; + localPath: string | null; + qiniuUrl: string | null; + errorMessage: string | null; + }>; +} + +export interface VideoGenerationParams { + projectId?: string; + elementId?: number; + shots?: Array<{ + id: number | string; + type?: string; + scene?: string; + voiceover?: string; + duration?: string | number; + voice_id?: string; + }>; +} + +const TASK_TIMEOUT_MS = 60 * 60 * 1000; + +export function useVideoGeneration() { + const [isGenerating, setIsGenerating] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const isActiveRef = useRef(true); + const abortRef = useRef<(() => void) | null>(null); + const timeoutRef = useRef | null>(null); + + const { submit, cancel } = useTask(); + + useEffect(() => { + return () => { + isActiveRef.current = false; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (abortRef.current) { + abortRef.current(); + } + }; + }, []); + + const reset = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (abortRef.current) { + abortRef.current(); + abortRef.current = null; + } + isActiveRef.current = true; + setIsGenerating(false); + setIsRegenerating(false); + setError(null); + setResult(null); + }, []); + + const startGeneration = useCallback( + async (params: VideoGenerationParams): Promise => { + if (!params.shots || params.shots.length === 0) { + setError('没有可生成的分镜'); + return null; + } + + isActiveRef.current = true; + setIsGenerating(true); + setError(null); + setResult(null); + + const shots = params.shots.map((shot) => ({ + id: shot.id, + type: shot.type || 'segment', + scene: shot.scene || '', + voiceover: shot.voiceover || '', + duration: shot.duration || '5s', + voice_id: shot.voice_id, + })); + + // 手动显示进度弹窗 + useProgressStore.getState().show('视频生成'); + + return new Promise((resolve) => { + let settled = false; + + const settle = (value: GenerationResult | null) => { + if (settled) return; + settled = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + abortRef.current = null; + resolve(value); + }; + + timeoutRef.current = setTimeout(() => { + if (!isActiveRef.current) return; + setError('任务处理超时'); + setIsGenerating(false); + settle(null); + }, TASK_TIMEOUT_MS); + + const run = async () => { + try { + const taskId = await submit( + 'video', + { + shots, + element_id: params.elementId, + }, + { + projectId: params.projectId, + showProgress: false, + callbacks: { + onComplete: (taskResult) => { + if (!isActiveRef.current) return; + const data = taskResult as GenerationResult; + if (data.completed === 0 && data.failed > 0) { + const firstError = data.shots.find((s) => s.errorMessage)?.errorMessage || '视频生成失败'; + setError(firstError); + setIsGenerating(false); + settle(null); + return; + } + setResult(data); + setIsGenerating(false); + settle(data); + }, + onError: (errMessage: string) => { + if (!isActiveRef.current) return; + setError(errMessage); + setIsGenerating(false); + settle(null); + }, + }, + } + ); + + if (!taskId) { + setIsGenerating(false); + setError('创建任务失败'); + settle(null); + return; + } + + abortRef.current = () => { + cancel(taskId); + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '视频生成失败'; + setError(errorMsg); + setIsGenerating(false); + settle(null); + } + }; + + run(); + }); + }, + [submit, cancel] + ); + + const regenerateShot = useCallback( + async (params: { + projectId?: string; + elementId?: number; + shot: { + id: number | string; + type?: string; + scene?: string; + voiceover?: string; + duration?: string | number; + voice_id?: string; + }; + }): Promise => { + if (!params.shot) { + return null; + } + + isActiveRef.current = true; + setIsRegenerating(true); + setError(null); + + // 手动显示进度弹窗(单镜头用 percent) + useProgressStore.getState().show('视频生成'); + + return new Promise((resolve) => { + let settled = false; + + const settle = (value: GenerationResult['shots'][0] | null) => { + if (settled) return; + settled = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + abortRef.current = null; + resolve(value); + }; + + timeoutRef.current = setTimeout(() => { + if (!isActiveRef.current) return; + setError('任务处理超时'); + setIsRegenerating(false); + settle(null); + }, TASK_TIMEOUT_MS); + + const run = async () => { + const shots = [{ + id: params.shot.id, + type: params.shot.type || 'segment', + scene: params.shot.scene || '', + voiceover: params.shot.voiceover || '', + duration: params.shot.duration || '5s', + voice_id: params.shot.voice_id, + }]; + + try { + const taskId = await submit( + 'video', + { + shots, + element_id: params.elementId, + }, + { + projectId: params.projectId, + showProgress: false, + callbacks: { + onComplete: (taskResult) => { + if (!isActiveRef.current) return; + const data = taskResult as GenerationResult; + const shotResult = data.shots[0]; + if (!shotResult || shotResult.status !== 'completed') { + const errorMsg = shotResult?.errorMessage || '视频生成失败'; + setError(errorMsg); + setIsRegenerating(false); + settle(null); + return; + } + setResult(data); + setIsRegenerating(false); + settle(shotResult); + }, + onError: (errMessage: string) => { + if (!isActiveRef.current) return; + setError(errMessage); + setIsRegenerating(false); + settle(null); + }, + }, + } + ); + + if (!taskId) { + setIsRegenerating(false); + setError('创建任务失败'); + settle(null); + return; + } + + abortRef.current = () => { + cancel(taskId); + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '视频生成失败'; + setError(errorMsg); + setIsRegenerating(false); + settle(null); + } + }; + + run(); + }); + }, + [submit, cancel] + ); + + const abort = useCallback(() => { + if (abortRef.current) { + abortRef.current(); + abortRef.current = null; + } + isActiveRef.current = false; + setIsGenerating(false); + setIsRegenerating(false); + }, []); + + return { + isGenerating, + isRegenerating, + error, + result, + startGeneration, + regenerateShot, + abort, + reset, + }; +} + +export default useVideoGeneration; diff --git a/tauri-app/src/main.tsx b/tauri-app/src/main.tsx new file mode 100644 index 0000000..3f30900 --- /dev/null +++ b/tauri-app/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './styles/variables.css'; +import './styles/global.css'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + +); diff --git a/tauri-app/src/pages/ContentManagement/AvatarCard.tsx b/tauri-app/src/pages/ContentManagement/AvatarCard.tsx new file mode 100644 index 0000000..e5c5438 --- /dev/null +++ b/tauri-app/src/pages/ContentManagement/AvatarCard.tsx @@ -0,0 +1,236 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import { AvatarItem } from '../../api/modules/avatar'; +import { useCachedVideoUrl, useCachedPosterUrl } from '../../hooks/useAvatarCache'; + +interface AvatarCardProps { + avatar: AvatarItem; + isEditing: boolean; + editName: string; + isSaving: boolean; + onEditChange: (val: string) => void; + onEditConfirm: () => void; + onEditCancel: () => void; + onStartEdit: (e: React.MouseEvent, avatar: AvatarItem) => void; + onDelete: (avatar: AvatarItem) => void; + formatDate: (dateStr?: string) => string; +} + +const AvatarCard: React.FC = ({ + avatar, + isEditing, + editName, + isSaving, + onEditChange, + onEditConfirm, + onEditCancel, + onStartEdit, + onDelete, + formatDate, +}) => { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + + // 使用本地缓存 - 优先使用本地视频文件 + const { videoUrl: cachedVideoUrl, isCached } = useCachedVideoUrl( + avatar.id, + avatar.videoUrl + ); + + // 生成视频封面 + const generatePoster = useCallback((): Promise => { + return new Promise((resolve) => { + if (!cachedVideoUrl) { + resolve(''); + return; + } + + const video = document.createElement('video'); + // 本地文件不需要跨域 + if (!isCached) { + video.crossOrigin = 'anonymous'; + } + video.src = cachedVideoUrl; + video.muted = true; + video.playsInline = true; + + video.onloadeddata = () => { + video.currentTime = 0.1; + }; + + video.onseeked = () => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth || 300; + canvas.height = video.videoHeight || 400; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const poster = canvas.toDataURL('image/jpeg', 0.8); + resolve(poster); + } else { + resolve(''); + } + }; + + video.onerror = () => { + console.warn('[AvatarCard] Failed to load video for poster:', cachedVideoUrl); + resolve(''); + }; + + video.load(); + }); + }, [cachedVideoUrl, isCached]); + + // 使用封面缓存 - 如果有本地缓存直接使用 + const { posterUrl, isLoaded } = useCachedPosterUrl(avatar.id, generatePoster); + + // 播放/暂停切换 + const togglePlay = (e: React.MouseEvent) => { + e.stopPropagation(); + const video = videoRef.current; + if (!video) return; + + if (video.paused) { + video.play(); + setIsPlaying(true); + } else { + video.pause(); + setIsPlaying(false); + } + }; + + // 监听视频状态变化 + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleEnded = () => setIsPlaying(false); + + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + video.addEventListener('ended', handleEnded); + + return () => { + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); + video.removeEventListener('ended', handleEnded); + }; + }, []); + + return ( +
+
+ {cachedVideoUrl ? ( + <> + {!isLoaded && ( +
+ )} +
+ +
+ {isEditing ? ( + onEditChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onEditConfirm(); + if (e.key === 'Escape') onEditCancel(); + }} + onBlur={() => onEditConfirm()} + autoFocus + disabled={isSaving} + /> + ) : ( +
+ {avatar.name} +
+ )} +
+ {formatDate(avatar.recordTime)} +
+
+
+ ); +}; + +export default AvatarCard; diff --git a/tauri-app/src/pages/ContentManagement/AvatarClone.tsx b/tauri-app/src/pages/ContentManagement/AvatarClone.tsx new file mode 100644 index 0000000..ef0867b --- /dev/null +++ b/tauri-app/src/pages/ContentManagement/AvatarClone.tsx @@ -0,0 +1,274 @@ +import { useState, useEffect } from 'react'; +import './ContentManagement.css'; +import type { AvatarItem } from '../../api/modules/avatar'; +import ConfirmModal from '../../components/Modal/ConfirmModal'; +import AvatarUploadModal from '../../components/Modal/AvatarUploadModal'; +import { useAvatarLibrary } from '../../hooks/useAvatarLibrary'; +import { deleteAvatarCache } from '../../hooks/useAvatarCache'; +import { + loadClonedAvatarsFromLocal, + saveClonedAvatarsToLocal, + removeClonedAvatarFromLocal, +} from '../../utils/avatarStorage'; +import AvatarCard from './AvatarCard'; + +function formatDate(dateStr?: string) { + if (!dateStr) { + return ''; + } + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return dateStr; + } + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; + } catch { + return dateStr; + } +} + +export default function AvatarClone() { + const [search, setSearch] = useState(''); + + const [showUploadModal, setShowUploadModal] = useState(false); + + const { avatarLibrary, mutate: refreshAvatarLibrary } = useAvatarLibrary(); + + // 编辑状态 + const [editingAvatar, setEditingAvatar] = useState(null); + const [editName, setEditName] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + // 删除确认弹窗状态 + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletingAvatar, setDeletingAvatar] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // 组件加载时刷新数据 + useEffect(() => { + localStorage.removeItem('swr-avatar-library'); + refreshAvatarLibrary(); + }, [refreshAvatarLibrary]); + + const clonedAvatars = avatarLibrary.filter(a => a.elementId && a.elementId > 0); + const filtered = clonedAvatars.filter(a => a.name.toLowerCase().includes(search.toLowerCase())); + + // 分页 + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + const totalPages = Math.ceil(filtered.length / pageSize); + const paginatedAvatars = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + // 搜索时重置分页 + useEffect(() => { + setCurrentPage(1); + }, [search]); + + // ========== 编辑(重命名 - 行内) ========== + const startInlineEdit = (e: React.MouseEvent, avatar: AvatarItem) => { + e.stopPropagation(); + setEditingAvatar(avatar); + setEditName(avatar.name); + }; + + const confirmRename = async () => { + if (!editingAvatar) { + return; + } + + const newName = editName.trim(); + if (!newName) { + return; + } + + if (newName === editingAvatar.name) { + setEditingAvatar(null); + return; + } + + setIsSaving(true); + try { + // 仅更新本地 + const clonedAvatars = await loadClonedAvatarsFromLocal(); + const updated = clonedAvatars.map(a => + a.id === editingAvatar.id ? { ...a, name: newName } : a + ); + await saveClonedAvatarsToLocal(updated); + + setEditingAvatar(null); + await refreshAvatarLibrary(); + } catch (e: any) { + } finally { + setIsSaving(false); + } + }; + + // ========== 删除 ========== + const openDeleteModal = (avatar: AvatarItem) => { + setDeletingAvatar(avatar); + setShowDeleteModal(true); + }; + + const confirmDelete = async () => { + if (!deletingAvatar) { + return; + } + setIsDeleting(true); + + // 删除本地文件缓存 + try { + await deleteAvatarCache(deletingAvatar.id); + console.log('[AvatarClone] Local cache deleted for:', deletingAvatar.id); + } catch (e) { + console.warn('[AvatarClone] Failed to delete local cache:', e); + // 忽略缓存删除失败,不影响主流程 + } + + removeClonedAvatarFromLocal(deletingAvatar.id); + + setShowDeleteModal(false); + setDeletingAvatar(null); + await refreshAvatarLibrary(); + setIsDeleting(false); + }; + + // ========== 上传成功回调 ========== + const handleUploadSuccess = () => { + // 刷新库列表(useAvatarLibrary 会自动合并本地和后端数据) + refreshAvatarLibrary(); + setShowUploadModal(false); + }; + + return ( +
+
+

形象克隆

+
+
+ + + + + setSearch(e.target.value)} + /> +
+ +
+
+ +
+ {paginatedAvatars.length > 0 ? ( + paginatedAvatars.map(avatar => ( + setEditingAvatar(null)} + onStartEdit={startInlineEdit} + onDelete={openDeleteModal} + formatDate={formatDate} + /> + )) + ) : ( +
+
+ + + + +
+

暂无克隆形象

+

点击"上传视频"添加素材

+
+ )} +
+ + {/* 分页 */} + {totalPages > 1 && ( +
+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( + + ))} + +
+
+ )} + + {/* 上传弹窗 */} + setShowUploadModal(false)} + onSuccess={handleUploadSuccess} + /> + + {/* 删除确认弹窗 */} + 确认删除形象 「{deletingAvatar.name}」 吗? : ''} + description="此操作不可撤销,本地记录将同时被清除" + confirmText={isDeleting ? '删除中...' : '确认删除'} + cancelText="取消" + confirmButtonType="danger" + onConfirm={confirmDelete} + onCancel={() => !isDeleting && setShowDeleteModal(false)} + /> +
+ ); +} diff --git a/tauri-app/src/pages/ContentManagement/ContentManagement.css b/tauri-app/src/pages/ContentManagement/ContentManagement.css new file mode 100644 index 0000000..b627a58 --- /dev/null +++ b/tauri-app/src/pages/ContentManagement/ContentManagement.css @@ -0,0 +1,1284 @@ +.content-page { + display: flex; + flex-direction: column; + gap: var(--spacing-xl); + height: 100%; +} + +.content-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-lg); +} + +.content-header h2 { + font-size: var(--font-xl); +} + +.content-search { + display: flex; + align-items: center; + gap: var(--spacing-md); + flex: 1; + max-width: 400px; +} + +.content-search .btn { + height: 40px; +} + +.search-input-wrapper { + position: relative; + flex: 1; +} + +.search-input-wrapper svg { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-tertiary); + pointer-events: none; +} + +.search-input-wrapper input { + padding-left: 38px; + height: 40px; +} + +.content-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: var(--spacing-lg); +} + +.voice-card { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-lg) var(--spacing-md); +} + +.voice-avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-full); + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: white; +} + +.voice-info { + flex: 1; + min-width: 0; +} + +.voice-name { + font-weight: 600; + font-size: var(--font-base); + color: var(--text-primary); + margin-bottom: var(--spacing-xs); +} + +/* 行内编辑输入框 */ +.voice-name-input { + padding: var(--spacing-xs) var(--spacing-sm); + border: 2px solid var(--primary); + border-radius: var(--radius-md); + font-size: var(--font-base); + font-weight: 600; + font-family: inherit; + background: var(--bg-card); + color: var(--text-primary); + width: 100px; + max-width: 100%; + outline: none; + transition: all var(--transition-fast); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.voice-name-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--primary-light); +} + +.voice-name-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.voice-meta { + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +.voice-actions { + display: flex; + gap: var(--spacing-xs); +} + +/* Digital Human Card */ +.dh-card { + overflow: hidden; +} + +.dh-preview { + height: 180px; + background: var(--bg-input); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + position: relative; +} + +.dh-preview-overlay { + position: absolute; + inset: 0; + background: rgb(0 0 0 / 40%); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.dh-card:hover .dh-preview-overlay { + opacity: 1; +} + +.dh-card-info { + padding: var(--spacing-md) var(--spacing-lg); + display: flex; + align-items: center; + justify-content: space-between; +} + +.dh-card-name { + font-weight: 500; + font-size: var(--font-sm); + color: var(--text-primary); +} + +/* Works Card */ +.works-tabs { + display: flex; + gap: var(--spacing-xs); + border-bottom: 1px solid var(--border-light); + padding-bottom: 0; +} + +.works-tab { + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-sm); + font-family: var(--font-family); + color: var(--text-tertiary); + background: none; + border: none; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all var(--transition-fast); + margin-bottom: -1px; +} + +.works-tab:hover { + color: var(--text-primary); +} + +.works-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); + font-weight: 500; +} + +.work-card { + overflow: hidden; + cursor: pointer; +} + +.work-card:hover { + transform: translateY(-2px); +} + +.work-thumb { + height: 160px; + background: var(--bg-input); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + position: relative; +} + +.work-duration { + position: absolute; + bottom: var(--spacing-sm); + right: var(--spacing-sm); + background: rgb(0 0 0 / 60%); + color: white; + font-size: var(--font-xs); + padding: var(--spacing-2xs) var(--spacing-xs); + border-radius: var(--radius-sm); +} + +.work-info { + padding: var(--spacing-md) var(--spacing-lg); +} + +.work-title { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Settings Page */ +.settings-page { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.settings-section { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.settings-section h2 { + font-size: var(--font-xl); +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-lg); + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border-light); +} + +.settings-row-label { + font-size: var(--font-sm); + color: var(--text-secondary); +} + +.settings-row-value { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); +} + +/* Profile Page */ +.profile-page { + max-width: 640px; + display: flex; + flex-direction: column; + gap: var(--spacing-xl); +} + +.profile-header { + display: flex; + align-items: center; + gap: var(--spacing-xl); + padding: var(--spacing-2xl); + background: var(--bg-card); + border-radius: var(--radius-xl); + border: 1px solid var(--border-light); +} + +.profile-avatar { + width: 72px; + height: 72px; + border-radius: var(--radius-full); + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: var(--font-2xl); + font-weight: 700; + flex-shrink: 0; +} + +.profile-name { + font-size: var(--font-xl); + font-weight: 600; +} + +.profile-role { + font-size: var(--font-sm); + color: var(--text-tertiary); +} + +/* Avatar Clone Card - 这个就是我们要复用的样式 */ +.avatar-card { + position: relative; + background: var(--bg-card); + border-radius: var(--radius-xl); + border: 1px solid var(--border-light); + padding: var(--spacing-sm); + transition: all var(--transition-normal) cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + cursor: default; + align-self: start; +} + +.avatar-card:hover { + transform: translateY(-4px); + border-color: var(--primary-light); + box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.12); +} + +.avatar-card-thumb-container { + width: 100%; + aspect-ratio: 3/4; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg-input); + position: relative; + z-index: 1; +} + +.avatar-card-video { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-normal) ease, opacity 0.3s ease; + background: var(--bg-input); + display: block; +} + +/* 居中播放按钮 - 默认隐藏,悬停显示 */ +.avatar-card-thumb-container .avatar-card-play-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.4); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0 !important; + transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease; + z-index: 10; + padding: 0; +} + +/* 鼠标悬停显示按钮 */ +.avatar-card-thumb-container:hover .avatar-card-play-btn, +.avatar-card-thumb-container.playing .avatar-card-play-btn { + opacity: 1 !important; +} + +.avatar-card-thumb-container .avatar-card-play-btn:hover { + background: rgba(255, 255, 255, 0.4); + transform: translate(-50%, -50%) scale(1.1); +} + +.avatar-card-thumb-container .avatar-card-play-btn:active { + transform: translate(-50%, -50%) scale(0.95); +} + +/* 视频加载中状态 */ +.avatar-card-video-loading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--bg-input) 0%, var(--bg-hover) 100%); + color: var(--text-tertiary); + font-size: var(--font-sm); +} + +.avatar-card-video-loading::after { + content: ''; + width: 24px; + height: 24px; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.avatar-card:hover .avatar-card-video { + transform: scale(1.05); +} + +.avatar-card-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + color: var(--text-tertiary); + background: linear-gradient(135deg, var(--bg-input) 0%, var(--bg-hover) 100%); +} + +.avatar-card-placeholder-icon { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: rgb(255 255 255 / 50%); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + backdrop-filter: blur(4px); + border: 1px solid rgb(255 255 255 / 30%); +} + +.avatar-card-placeholder span { + font-size: var(--font-xs); + font-weight: 500; + letter-spacing: 0.5px; +} + +.avatar-card-actions { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + display: flex; + gap: var(--spacing-sm); + opacity: 0; + transform: translateY(-8px); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 10; +} + +.avatar-card:hover .avatar-card-actions { + opacity: 1; + transform: translateY(0); +} + +.avatar-card-action-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all var(--transition-fast); +} + +.avatar-card-action-btn:hover { + background: var(--primary); + border-color: var(--primary); + transform: scale(1.1); +} + +.avatar-card-action-btn.delete:hover { + background: var(--danger); + border-color: var(--danger); +} + +.avatar-card-info { + padding: 0 var(--spacing-xs) var(--spacing-xs); + min-width: 0; +} + +.avatar-card-name { + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.avatar-card-date { + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +.avatar-card-name-input { + width: 100%; + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--primary); + border-radius: var(--radius-sm); + padding: 0 var(--spacing-xs); + height: 24px; + line-height: 24px; + outline: none; + margin-bottom: 4px; +} + +.avatar-card-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.2); + color: white; + opacity: 0; + transition: opacity var(--transition-normal); + pointer-events: none; + z-index: 5; +} + +.avatar-card:hover .avatar-card-play-overlay, +.avatar-card.playing .avatar-card-play-overlay { + opacity: 1; +} + +.avatar-card-thumb-container { + cursor: pointer; +} + +.avatar-card.playing { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light), 0 12px 24px -8px rgba(0, 0, 0, 0.12); +} + +.avatar-card.playing .avatar-card-video { + transform: scale(1.05); +} + +/* Avatar Grid Layout */ +.avatar-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--spacing-xl); + padding: var(--spacing-sm) var(--spacing-xs); + flex: 1; +} + +/* Content Page Compact variant */ +.content-page-compact { + gap: var(--spacing-md); +} + +/* Pagination Container */ +.pagination-container { + display: flex; + justify-content: center; + margin-top: 8px; +} + +/* Empty State Full Width */ +.empty-state-full { + grid-column: 1 / -1; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* Delete Confirmation Modal - 已迁移到 ConfirmModal 组件 */ + +/* Usage Detail - 使用明细表格 */ +.usage-filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-md); + gap: var(--spacing-md); +} + +.usage-filter-bar .filter-item { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.usage-filter-bar .filter-label { + font-size: var(--font-sm); + color: var(--text-secondary); + white-space: nowrap; +} + +.usage-filter-bar .filter-input-group { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.usage-filter-bar .filter-input-box { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--bg-input); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + font-size: var(--font-sm); + color: var(--text-primary); + min-width: 180px; +} + +.usage-filter-bar .filter-separator { + color: var(--text-tertiary); + font-size: var(--font-sm); +} + +.usage-table { + width: 100%; + border-collapse: collapse; +} + +.usage-table thead tr { + background: var(--bg-secondary); + border-bottom: 2px solid var(--border-light); +} + +.usage-table th { + padding: var(--spacing-md) var(--spacing-md); + text-align: left; + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; +} + +.usage-table tbody tr { + border-bottom: 1px solid var(--border-light); +} + +.usage-table tbody tr:hover { + background: var(--bg-hover); +} + +.usage-table td { + padding: var(--spacing-md) var(--spacing-md); + font-size: var(--font-sm); + color: var(--text-primary); +} + +/* End Usage Detail */ + +/* ============================================================ + 成品卡片样式 - 完全复用 avatar-card 风格 + ============================================================ */ + +/* Products Grid - 和 avatar-grid 完全一致 */ +.products-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--spacing-xl); + padding: var(--spacing-sm) var(--spacing-xs); + flex: 1; +} + +/* 成品卡片 - 和 avatar-card 完全一样 */ +.product-card { + position: relative; + background: var(--bg-card); + border-radius: var(--radius-xl); + border: 1px solid var(--border-light); + padding: var(--spacing-sm); + transition: all var(--transition-normal) cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + cursor: pointer; + align-self: start; +} + +.product-card:hover { + transform: translateY(-4px); + border-color: var(--primary-light); + box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.12); +} + +/* 成品视频容器 - 9:16 比例(视频尺寸) */ +.product-video-container { + width: 100%; + aspect-ratio: 9 / 16; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--bg-input); + position: relative !important; + z-index: 1; +} + +.product-video { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-normal) ease, opacity 0.3s ease; + background: var(--bg-input); + display: block; +} + +.product-card:hover .product-video { + transform: scale(1.05); +} + +/* 操作按钮 - 和 avatar-card-actions 位置样式一致 */ +.product-overlay { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + opacity: 0; + transform: translateY(-8px); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 10; + display: flex; + gap: var(--spacing-xs); +} + +.product-card:hover .product-overlay { + opacity: 1; + transform: translateY(0); +} + +.product-action-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all var(--transition-fast); +} + +.product-action-btn:hover { + transform: scale(1.1); +} + +.product-rename-btn:hover { + background: var(--primary); + border-color: var(--primary); +} + +.product-delete-btn:hover { + background: var(--danger); + border-color: var(--danger); +} + +/* 重命名编辑浮层 */ +.rename-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + z-index: 15; + border-radius: var(--radius-lg); +} + +.rename-form { + display: flex; + gap: var(--spacing-xs); + align-items: center; + background: var(--bg-card); + padding: var(--spacing-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--primary); + width: 100%; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.rename-form input { + flex: 1; + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-sm); + background: var(--bg-input); + border: none; + border-radius: var(--radius-md); + outline: none; + color: var(--text-primary); +} + +.rename-form .save-btn, +.rename-form .cancel-btn { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all var(--transition-fast); +} + +.rename-form .save-btn { + background: var(--primary); + color: white; +} + +.rename-form .save-btn:hover { + transform: scale(1.1); +} + +.rename-form .cancel-btn { + background: var(--bg-input); + color: var(--text-primary); +} + +.rename-form .cancel-btn:hover { + transform: scale(1.1); +} + +.product-info { + padding: 0 var(--spacing-xs) var(--spacing-xs); + min-width: 0; +} + +.product-name { + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.product-meta { + font-size: var(--font-xs); + color: var(--text-tertiary); + display: flex; + gap: 4px; +} + +.product-meta .separator { + opacity: 0.5; +} + +/* ============================================================ + 草稿箱列表样式 - 文字列表,无缩略图 + ============================================================ */ + +.drafts-list-container { + flex: 1; +} + +.drafts-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.draft-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-md) var(--spacing-lg); + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-fast); +} + +.draft-list-item:hover { + border-color: var(--primary-light); + background: var(--bg-hover); + transform: translateX(2px); +} + +.draft-info { + flex: 1; + min-width: 0; +} + +.draft-title { + font-size: var(--font-base); + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.draft-meta { + display: flex; + align-items: center; + gap: var(--spacing-md); + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +.draft-topic { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.8; +} + +.draft-arrow { + flex-shrink: 0; + color: var(--text-tertiary); + opacity: 0.5; +} + +.draft-list-item:hover .draft-arrow { + opacity: 0.8; +} + +/* ============================================================ + 我的作品 - 全新样式 + ============================================================ */ + +/* Tab 栏 - Pill 样式 */ +.works-tabs-bar { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + background: var(--bg-input); + border-radius: var(--radius-lg); + width: fit-content; +} + +.works-tab-pill { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-sm); + font-family: var(--font-family); + color: var(--text-secondary); + background: transparent; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; +} + +.works-tab-pill:hover { + color: var(--text-primary); +} + +.works-tab-pill.active { + background: var(--primary); + color: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.works-tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + font-size: var(--font-xs); + font-weight: 600; + background: rgba(0, 0, 0, 0.06); + border-radius: var(--radius-full); +} + +.works-tab-pill.active .works-tab-count { + background: rgba(255, 255, 255, 0.25); +} + +/* 网格布局 - 一行4个 */ +.works-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-lg); + flex: 1; + align-content: start; + align-items: start; +} + +/* 成品卡片 */ +.works-card { + position: relative; + background: #ffffff; + border-radius: var(--radius-xl); + border: 1px solid var(--border-light); + padding: var(--spacing-sm); + overflow: hidden; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + display: flex; + flex-direction: column; +} + +.works-card:hover { + transform: translateY(-2px); + border-color: var(--primary-light); + box-shadow: 0 8px 24px -6px rgba(0, 0, 0, 0.1); +} + +.works-card.playing { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light), 0 8px 24px -6px rgba(0, 0, 0, 0.1); +} + +/* 封面区域 - 9:16 比例 */ +.works-card-cover { + position: relative; + width: 100%; + aspect-ratio: 9 / 16; + background: var(--bg-input); + border-radius: var(--radius-lg); + overflow: hidden; + flex-shrink: 0; +} + +.works-card-poster { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 2; +} + +.works-card-video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* 播放按钮 */ +.works-card-play-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.25); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.2s ease; + z-index: 5; + padding: 0; +} + +.works-card:hover .works-card-play-btn, +.works-card.playing .works-card-play-btn { + opacity: 1; +} + +.works-card-play-btn:hover { + background: rgba(0, 0, 0, 0.6); + transform: translate(-50%, -50%) scale(1.1); +} + +/* 加载状态 */ +.works-card-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-input); + z-index: 3; +} + +.works-card-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-light); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* 占位 */ +.works-card-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); +} + +/* 操作按钮 */ +.works-card-overlay { + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + display: flex; + gap: var(--spacing-xs); + opacity: 0; + transform: translateY(-4px); + transition: all 0.2s ease; + z-index: 10; +} + +.works-card:hover .works-card-overlay { + opacity: 1; + transform: translateY(0); +} + +.works-card-action { + width: 28px; + height: 28px; + border-radius: var(--radius-full); + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; + padding: 0; +} + +.works-card-action:hover { + background: var(--primary); + border-color: var(--primary); + transform: scale(1.1); +} + +.works-card-action.delete:hover { + background: var(--danger); + border-color: var(--danger); +} + +/* 信息区域 */ +.works-card-info { + padding: var(--spacing-md) var(--spacing-lg); +} + +.works-card-name { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.works-card-meta { + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +/* 重命名 */ +.works-card-rename { + display: flex; + align-items: center; + gap: var(--spacing-xs); + margin-bottom: 4px; +} + +.works-card-rename-input { + flex: 1; + min-width: 0; + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--primary); + border-radius: var(--radius-md); + outline: none; + font-family: inherit; +} + +.works-card-rename-ext { + font-size: var(--font-sm); + color: var(--text-tertiary); + font-weight: 500; + flex-shrink: 0; +} + +/* 分页 - 与 VideoGeneration 统一 */ +.pagination { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs); + background: var(--bg-input); + border-radius: var(--radius-lg); +} + +.pagination-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.pagination-btn:hover:not(:disabled) { + background: var(--bg-card); + color: var(--text-primary); +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-btn.active { + background: var(--primary); + color: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.pagination-btn.nav { + width: auto; + padding: 0 var(--spacing-sm); + font-size: var(--font-xs); +} diff --git a/tauri-app/src/pages/ContentManagement/MyWorks.tsx b/tauri-app/src/pages/ContentManagement/MyWorks.tsx new file mode 100644 index 0000000..ee929ec --- /dev/null +++ b/tauri-app/src/pages/ContentManagement/MyWorks.tsx @@ -0,0 +1,360 @@ +import { useState, useEffect, useRef } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { openPath } from '@tauri-apps/plugin-opener'; +import { readFile } from '@tauri-apps/plugin-fs'; +import { useNavigation } from '../../App'; +import { switchProject } from '../../store/projectStore'; +import ConfirmModal from '../../components/Modal/ConfirmModal'; +import { useLocalVideo } from '../../hooks/useLocalVideo'; +import type { ApiResponse } from '../../api/types'; +import './ContentManagement.css'; + +interface LocalProjectMeta { + id: string; + title: string; + topic?: string; + status: 'draft' | 'published'; + createdAt: number; + updatedAt: number; + coverPath?: string; + finalVideoPath?: string; +} + +interface ProductItem { + filename: string; + path: string; + created_at: number; + file_size: number; + poster_path?: string; +} + +interface DraftItem { + id: string; + title: string; + createdAt: number; + topic?: string; + updatedAt: number; +} + +type TabType = 'products' | 'drafts'; + +const tabs = [ + { id: 'products' as TabType, label: '成片' }, + { id: 'drafts' as TabType, label: '草稿箱' }, +]; + +const PAGE_SIZE = 8; + +function formatDateFriendly(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.round((today.getTime() - target.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays === 0) return '今天'; + if (diffDays === 1) return '昨天'; + if (diffDays < 7) return `${diffDays} 天前`; + return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function getBaseName(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + return lastDot > 0 ? filename.slice(0, lastDot) : filename; +} + +function getExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + return lastDot > 0 ? filename.slice(lastDot) : ''; +} + +function ProductCard({ product, onDelete, onRename }: { + product: ProductItem; + onDelete: (product: ProductItem) => void; + onRename: (product: ProductItem, newName: string) => void; +}) { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [editName, setEditName] = useState(getBaseName(product.filename)); + const [coverUrl, setCoverUrl] = useState(''); + const [isCoverLoading, setIsCoverLoading] = useState(false); + const { videoUrl } = useLocalVideo(product.path); + + useEffect(() => { + if (!product.poster_path) { + setCoverUrl(''); + setIsCoverLoading(false); + return; + } + let canceled = false; + let blobUrl: string | null = null; + async function load() { + setIsCoverLoading(true); + try { + const data = await readFile(product.poster_path!); + if (canceled) return; + const blob = new Blob([data], { type: 'image/jpeg' }); + blobUrl = URL.createObjectURL(blob); + setCoverUrl(blobUrl); + } catch (e) { + if (!canceled) setCoverUrl(''); + } finally { + if (!canceled) setIsCoverLoading(false); + } + } + load(); + return () => { + canceled = true; + if (blobUrl) URL.revokeObjectURL(blobUrl); + }; + }, [product.poster_path]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleEnded = () => setIsPlaying(false); + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + video.addEventListener('ended', handleEnded); + return () => { + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); + video.removeEventListener('ended', handleEnded); + }; + }, []); + + const togglePlay = (e: React.MouseEvent) => { + e.stopPropagation(); + const video = videoRef.current; + if (!video) return; + if (video.paused) { video.play(); setIsPlaying(true); } + else { video.pause(); setIsPlaying(false); } + }; + + const handleOpen = () => openPath(product.path); + + const startRename = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsRenaming(true); + setEditName(getBaseName(product.filename)); + }; + + const confirmRename = () => { + const base = editName.trim(); + if (!base) return; + const fullName = base + getExtension(product.filename); + if (fullName === product.filename) { setIsRenaming(false); return; } + onRename(product, fullName); + setIsRenaming(false); + }; + + const cancelRename = () => { + setIsRenaming(false); + setEditName(getBaseName(product.filename)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') cancelRename(); + }; + + return ( +
+
+ {videoUrl ? ( + <> + {isCoverLoading &&
} + {coverUrl && !isPlaying && } +
+
+ {isRenaming ? ( +
+ setEditName(e.target.value)} onKeyDown={handleKeyDown} onBlur={confirmRename} autoFocus /> + {getExtension(product.filename)} +
+ ) : ( +
{product.filename}
+ )} +
{formatDateFriendly(product.created_at)} · {formatFileSize(product.file_size)}
+
+
+ ); +} + +function DraftListItem({ draft, onClick }: { draft: DraftItem; onClick: (id: string) => void }) { + return ( +
onClick(draft.id)}> +
+
{draft.title}
+
+ {draft.topic && {draft.topic}} + {formatDateFriendly(draft.createdAt)} +
+
+ +
+ ); +} + +export default function MyWorks() { + const [activeTab, setActiveTab] = useState('products'); + const [products, setProducts] = useState([]); + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletingProduct, setDeletingProduct] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const { navigate } = useNavigation(); + + const loadProducts = async () => { + try { + const result = await invoke('list_local_products') as ApiResponse; + setProducts(result.data || []); + } catch (error) { + console.error('[MyWorks] Failed to load products:', error); + } + }; + + const loadDrafts = async () => { + try { + const result = await invoke('list_local_projects') as ApiResponse; + const data = result.data || []; + const draftItems: DraftItem[] = data.map(meta => ({ id: meta.id, title: meta.title || meta.id, createdAt: meta.createdAt, updatedAt: meta.updatedAt, topic: meta.topic })); + draftItems.sort((a, b) => b.updatedAt - a.updatedAt); + setDrafts(draftItems); + } catch (error) { + console.error('[MyWorks] Failed to load drafts:', error); + } + }; + + useEffect(() => { async function loadAll() { await Promise.all([loadProducts(), loadDrafts()]); setLoading(false); } loadAll(); }, []); + useEffect(() => { setCurrentPage(1); }, [activeTab]); + + const handleRenameProduct = async (product: ProductItem, newFilename: string) => { + await invoke('rename_local_product', { old_filename: product.filename, new_filename: newFilename }) as ApiResponse; + await loadProducts(); + }; + + const openDeleteModal = (product: ProductItem) => { setDeletingProduct(product); setShowDeleteModal(true); }; + + const confirmDelete = async () => { + if (!deletingProduct) return; + setIsDeleting(true); + await invoke('delete_local_product', { filename: deletingProduct.filename }) as ApiResponse; + await loadProducts(); + setShowDeleteModal(false); + setDeletingProduct(null); + setIsDeleting(false); + }; + + const handleOpenDraft = async (draftId: string) => { await switchProject(draftId); navigate('video-creation'); }; + + const totalPages = Math.ceil(products.length / PAGE_SIZE); + const paginatedProducts = products.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + return ( +
+

我的作品

+ +
+ {tabs.map(tab => ( + + ))} +
+ + {loading ? ( +

加载中...

+ ) : ( + <> + {activeTab === 'products' && ( + <> +
+ {paginatedProducts.length > 0 ? ( + paginatedProducts.map(product => ( + + )) + ) : ( +
+
+

暂无成片

+

完成视频合成后会保存在这里

+
+ )} +
+ {totalPages > 1 && ( +
+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( + + ))} + +
+
+ )} + + )} + {activeTab === 'drafts' && ( +
+ {drafts.length > 0 ? ( +
+ {drafts.map(draft => )} +
+ ) : ( +
+
+

暂无草稿

+

开始创作后会保存草稿在这里

+
+ )} +
+ )} + + )} + + 确认删除成品 「{deletingProduct.filename}」 吗? : ''} + description="此操作不可撤销,文件将从本地磁盘删除" + confirmText={isDeleting ? '删除中...' : '确认删除'} + cancelText="取消" + confirmButtonType="danger" + onConfirm={confirmDelete} + onCancel={() => !isDeleting && setShowDeleteModal(false)} + /> +
+ ); +} diff --git a/tauri-app/src/pages/Login/Login.css b/tauri-app/src/pages/Login/Login.css new file mode 100644 index 0000000..6ea8005 --- /dev/null +++ b/tauri-app/src/pages/Login/Login.css @@ -0,0 +1,350 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-main); + position: relative; + overflow: hidden; +} + +/* 背景装饰 */ +.login-page::before { + content: ''; + position: absolute; + top: -30%; + right: -10%; + width: 600px; + height: 600px; + border-radius: 50%; + background: radial-gradient(circle, rgb(54 178 106 / 8%) 0%, transparent 70%); + pointer-events: none; +} + +.login-page::after { + content: ''; + position: absolute; + bottom: -20%; + left: -10%; + width: 500px; + height: 500px; + border-radius: 50%; + background: radial-gradient(circle, rgb(24 160 138 / 6%) 0%, transparent 70%); + pointer-events: none; +} + +.login-container { + width: 100%; + max-width: 420px; + padding: var(--spacing-2xl); + position: relative; + z-index: 1; +} + +/* Logo & 品牌 */ +.login-brand { + text-align: center; + margin-bottom: var(--spacing-2xl); +} + +.login-logo { + display: inline-flex; + align-items: center; + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +.login-logo svg { + flex-shrink: 0; +} + +.login-logo-text { + font-size: var(--font-xl); + font-weight: 700; + background: var(--primary-gradient); + background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 0.5px; +} + +.login-subtitle { + color: var(--text-tertiary); + font-size: var(--font-sm); +} + +/* 登录卡片 */ +.login-card { + background: var(--bg-card); + border-radius: var(--radius-xl); + border: 1px solid var(--border-light); + padding: var(--spacing-2xl); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(10px); +} + +.login-card h2 { + font-size: var(--font-xl); + font-weight: 600; + margin-bottom: var(--spacing-xs); +} + +.login-card-desc { + color: var(--text-tertiary); + font-size: var(--font-sm); + margin-bottom: var(--spacing-xl); +} + +/* 表单 */ +.login-form { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.form-field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.form-label { + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-secondary); +} + +.phone-input-group { + display: flex; + align-items: center; + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + overflow: hidden; + transition: border-color var(--transition-fast); + background: var(--bg-input); +} + +.phone-input-group:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgb(54 178 106 / 10%); +} + +.phone-prefix { + padding: 0 var(--spacing-md); + font-size: var(--font-base); + font-weight: 600; + color: var(--text-primary); + border-right: 1px solid var(--border-light); + display: flex; + align-items: center; + height: 48px; + flex-shrink: 0; + user-select: none; +} + +.phone-input-group input { + border: none; + background: transparent; + flex: 1; + height: 48px; + padding: 0 var(--spacing-md); + font-size: var(--font-base); + font-weight: 600; + letter-spacing: 2px; + outline: none; + color: var(--text-primary); +} + +.phone-input-group input::placeholder { + letter-spacing: normal; + font-weight: 400; + color: var(--text-placeholder); +} + +.code-input-group { + display: flex; + gap: var(--spacing-sm); +} + +.code-input-group input { + flex: 1; + height: 48px; + border-radius: var(--radius-lg); + border: 1px solid var(--border-light); + padding: 0 var(--spacing-md); + font-size: var(--font-base); + background: var(--bg-input); + color: var(--text-primary); + transition: border-color var(--transition-fast); + letter-spacing: 4px; + font-weight: 600; +} + +.code-input-group input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgb(54 178 106 / 10%); + outline: none; +} + +.code-input-group input::placeholder { + letter-spacing: normal; + font-weight: 400; + color: var(--text-placeholder); +} + +.send-code-btn { + flex-shrink: 0; + height: 48px; + padding: 0 var(--spacing-lg); + border: 1px solid var(--primary); + border-radius: var(--radius-lg); + background: transparent; + color: var(--primary); + font-size: var(--font-sm); + font-weight: 500; + font-family: var(--font-family); + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.send-code-btn:hover:not(:disabled) { + background: rgb(54 178 106 / 8%); +} + +.send-code-btn:disabled { + border-color: var(--border-light); + color: var(--text-tertiary); + cursor: not-allowed; +} + +/* 登录按钮 */ +.login-btn { + width: 100%; + height: 48px; + border: none; + border-radius: var(--radius-lg); + background: var(--primary-gradient); + color: white; + font-size: var(--font-base); + font-weight: 600; + font-family: var(--font-family); + cursor: pointer; + transition: all var(--transition-normal); + position: relative; + overflow: hidden; + margin-top: var(--spacing-sm); +} + +.login-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgb(255 255 255 / 15%) 0%, transparent 50%); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.login-btn:hover:not(:disabled)::before { + opacity: 1; +} + +.login-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 16px rgb(54 178 106 / 35%); +} + +.login-btn:active:not(:disabled) { + transform: translateY(0); +} + +.login-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.login-btn-loading { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); +} + +/* 协议 */ +.login-agreement { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-top: var(--spacing-xs); +} + +.login-agreement input[type='checkbox'] { + appearance: none; + appearance: none; + box-sizing: border-box; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + padding: 0; + margin: 0; + aspect-ratio: 1 / 1; + border: 1px solid var(--border-color, #dcdfe6); + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + position: relative; + background-color: var(--bg-card, #fff); + transition: all var(--transition-normal, 0.3s cubic-bezier(0.4, 0, 0.2, 1)); +} + +.login-agreement input[type='checkbox']:hover { + border-color: var(--primary); +} + +.login-agreement input[type='checkbox']:checked { + background-color: var(--primary); + border-color: var(--primary); + + /* SVG checkmark perfectly matching the rounded, thick reference */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); + background-size: 70%; + background-position: center; + background-repeat: no-repeat; + animation: checkbox-pop 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes checkbox-pop { + 0% { + transform: scale(0.8); + } + + 100% { + transform: scale(1); + } +} + +.login-agreement label { + font-size: var(--font-xs); + color: var(--text-tertiary); + line-height: 1.5; + cursor: pointer; +} + +.login-agreement a { + color: var(--primary); + text-decoration: none; +} + +.login-agreement a:hover { + text-decoration: underline; +} + +/* 底部 */ +.login-footer { + text-align: center; + margin-top: var(--spacing-xl); + color: var(--text-placeholder); + font-size: var(--font-xs); +} diff --git a/tauri-app/src/pages/Login/Login.tsx b/tauri-app/src/pages/Login/Login.tsx new file mode 100644 index 0000000..6ab56ac --- /dev/null +++ b/tauri-app/src/pages/Login/Login.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useAuthStore } from '../../store'; +import { toast } from '../../store/uiStore'; +import './Login.css'; + +/** + * 登录页面 + * + * 改进: + * - 使用全局 authStore 替代局部的 onLoginSuccess 回调 + * - 使用新的 uiStore toast 方法 + */ +export default function Login() { + const { login, isLoading } = useAuthStore(); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [agreed, setAgreed] = useState(false); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [logging, setLogging] = useState(false); + + // 倒计时逻辑(必须放在所有条件返回之前,遵循 Hooks 规则) + useEffect(() => { + if (countdown <= 0) { + return; + } + const timer = setTimeout(() => setCountdown(c => c - 1), 1000); + return () => clearTimeout(timer); + }, [countdown]); + + // 手机号格式验证 + const isPhoneValid = /^1[3-9]\d{9}$/.test(phone); + const isCodeValid = /^\d{4,6}$/.test(code); + const canLogin = isPhoneValid && isCodeValid && agreed && !logging; + + // 发送验证码 + const handleSendCode = useCallback(() => { + if (!isPhoneValid || countdown > 0 || sending) { + return; + } + setSending(true); + // 模拟发送 + setTimeout(() => { + setSending(false); + setCountdown(60); + toast.success('验证码已发送'); + }, 800); + }, [isPhoneValid, countdown, sending]); + + // 登录 + const handleLogin = async () => { + if (!canLogin) { + return; + } + + setLogging(true); + try { + await login(phone, code); + // 登录成功后 authStore 会自动更新全局状态 + // App.tsx 会检测到 isAuthenticated 变化并切换界面 + toast.success('登录成功'); + } catch (error: any) { + toast.error(error.message || '登录失败,请重试'); + } finally { + setLogging(false); + } + }; + + // 回车键登录 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && canLogin) { + handleLogin(); + } + }; + + return ( +
+
+ {/* 品牌 */} +
+
+ 美家卡 智影 + 美家卡 智影 +
+

AI 驱动的智能视频创作平台

+
+ + {/* 登录卡片 */} +
+

欢迎登录

+

使用手机号验证码快速登录

+ + {/* 加载状态 */} + {isLoading ? ( +
+ + + +

加载中...

+
+ ) : ( +
+ {/* 手机号 */} +
+ +
+ +86 + { + const v = e.target.value.replace(/\D/g, '').slice(0, 11); + setPhone(v); + }} + maxLength={11} + autoFocus + /> +
+
+ + {/* 验证码 */} +
+ +
+ { + const v = e.target.value.replace(/\D/g, '').slice(0, 6); + setCode(v); + }} + maxLength={6} + /> + +
+
+ + {/* 登录按钮 */} + + + {/* 协议 */} +
+ setAgreed(e.target.checked)} + /> + +
+
+ )} +
+ +
meijiaka.cn
+
+
+ ); +} diff --git a/tauri-app/src/pages/Profile/Profile.tsx b/tauri-app/src/pages/Profile/Profile.tsx new file mode 100644 index 0000000..05b9388 --- /dev/null +++ b/tauri-app/src/pages/Profile/Profile.tsx @@ -0,0 +1,64 @@ +import { useNavigation } from '../../App'; +import '../ContentManagement/ContentManagement.css'; + +export default function Profile() { + const { navigate } = useNavigation(); + + return ( +
+
+
U
+
+
用户
+
免费版用户
+
+
+ +
+

个人信息

+
+
+ 昵称 + + 用户 + + +
+ +
+ 绑定手机 + 138****8888 +
+ +
+ 剩余额度 + + 100 次 + + +
+
+
+ +
+

使用记录

+
+
+ 使用明细 +
+ +
+
+
+
+
+ ); +} diff --git a/tauri-app/src/pages/Profile/UsageDetail.tsx b/tauri-app/src/pages/Profile/UsageDetail.tsx new file mode 100644 index 0000000..ec43e41 --- /dev/null +++ b/tauri-app/src/pages/Profile/UsageDetail.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import '../ContentManagement/ContentManagement.css'; + +interface UsageRecord { + id: string; + packageName: string; + packageId: string; + mode: string; + deductTime: string; + beforeQuota: number; + deductAmount: number; + afterQuota: number; +} + +// 模拟数据 +const mockData: UsageRecord[] = [ + { + id: '1', + packageName: '试用-视频生成-1000', + packageId: '869351574496624657', + mode: '4K画质 有声 音色', + deductTime: '2026-04-15 16:29:19', + beforeQuota: 512.4, + deductAmount: 6, + afterQuota: 506.4, + }, + { + id: '2', + packageName: '试用-视频生成-1000', + packageId: '869351574496624657', + mode: '4K画质 有声', + deductTime: '2026-04-15 16:28:16', + beforeQuota: 517.4, + deductAmount: 5, + afterQuota: 512.4, + }, + { + id: '3', + packageName: '试用-视频生成-1000', + packageId: '869351574496624657', + mode: '4K画质 有声', + deductTime: '2026-04-15 16:28:09', + beforeQuota: 522.4, + deductAmount: 5, + afterQuota: 517.4, + }, + { + id: '4', + packageName: '试用-图像生成-1000', + packageId: '871455826052554831', + mode: '', + deductTime: '2026-04-15 16:26:54', + beforeQuota: 816, + deductAmount: 8, + afterQuota: 808, + }, + { + id: '5', + packageName: '试用-视频生成-1000', + packageId: '869351574496624657', + mode: '4K画质 有声 音色', + deductTime: '2026-04-15 16:22:16', + beforeQuota: 528.4, + deductAmount: 6, + afterQuota: 522.4, + }, +]; + +export default function UsageDetail() { + const [startDate] = useState('2026-03-17 00:00:00'); + const [endDate] = useState('2026-04-15 23:59:59'); + + const handleExport = () => { + // 导出数据功能占位 + console.log('Export data...'); + }; + + return ( +
+
+

使用明细

+ + {/* 筛选区 */} +
+
+ 创建时间 +
+ {startDate} + To + {endDate} +
+
+ +
+ + {/* 使用明细表 */} +
+ + + + + + + + + + + + + + {mockData.map(record => ( + + + + + + + + + + ))} + +
资源包名称资源包ID模式抵扣时间 + + 抵扣前余量(次)抵扣量(次)抵扣后余量(次)
{record.packageName}{record.packageId}{record.mode}{record.deductTime}{record.beforeQuota}{record.deductAmount}{record.afterQuota}
+
+
+
+ ); +} diff --git a/tauri-app/src/pages/Settings/AboutUs.tsx b/tauri-app/src/pages/Settings/AboutUs.tsx new file mode 100644 index 0000000..e42f3d5 --- /dev/null +++ b/tauri-app/src/pages/Settings/AboutUs.tsx @@ -0,0 +1,44 @@ +import '../ContentManagement/ContentManagement.css'; + +export default function AboutUs() { + return ( +
+
+

关于我们

+
+
+ 应用名称 + 美家卡 智影 +
+
+ 版本号 + V 1.0.2 +
+
+
+ +
+

授权信息

+
+

+ 本软件由美家卡团队开发维护。授权用户可在授权范围内使用本软件进行视频创作。 + 如需商业授权或有任何疑问,请联系我们的支持团队。 +

+
+
+ +
+

版权声明

+
+

+ Copyright 2025 美家卡 (meijiaka.cn). All rights reserved. +

+

+ 本软件及其相关文档的所有权利均归美家卡所有。 + 未经授权,不得复制、修改、分发或以其他方式使用本软件。 +

+
+
+
+ ); +} diff --git a/tauri-app/src/pages/Settings/SystemUpdate.tsx b/tauri-app/src/pages/Settings/SystemUpdate.tsx new file mode 100644 index 0000000..a61a6b1 --- /dev/null +++ b/tauri-app/src/pages/Settings/SystemUpdate.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import '../ContentManagement/ContentManagement.css'; + +export default function SystemUpdate() { + const [checking, setChecking] = useState(false); + const [checked, setChecked] = useState(false); + + const handleCheck = () => { + setChecking(true); + setChecked(false); + setTimeout(() => { + setChecking(false); + setChecked(true); + }, 2000); + }; + + return ( +
+
+

系统更新

+
+
+ 当前版本 + V 1.0.2 +
+
+ 版本更新 +
+ {checking ? ( + + + + + 检查中... + + ) : checked ? ( + 当前已是最新版本 + ) : ( + 未检查 + )} + +
+
+
+
+
+ ); +} diff --git a/tauri-app/src/pages/Settings/ThemeSettings.tsx b/tauri-app/src/pages/Settings/ThemeSettings.tsx new file mode 100644 index 0000000..2ab53f1 --- /dev/null +++ b/tauri-app/src/pages/Settings/ThemeSettings.tsx @@ -0,0 +1,192 @@ +import type { ReactNode } from 'react'; +import { useSettingsStore, type ThemeMode } from '../../store'; +import '../ContentManagement/ContentManagement.css'; + +const themeOptions: { id: ThemeMode; label: string; desc: string; icon: ReactNode }[] = [ + { + id: 'system', + label: '跟随系统', + desc: '自动匹配系统深浅色设置', + icon: ( + + + + + + ), + }, + { + id: 'dark', + label: '深色模式', + desc: '暗色背景,减少视觉疲劳', + icon: ( + + + + ), + }, + { + id: 'light', + label: '浅色模式', + desc: '明亮背景,清晰舒适', + icon: ( + + + + + + + + + + + + ), + }, +]; + +/** + * 主题设置页面 + * + * 改进: + * - 使用全局 settingsStore 替代局部的 localStorage 操作 + * - 主题变更会自动应用到全局并持久化 + */ +export default function ThemeSettings() { + const { theme, setTheme, isDark } = useSettingsStore(); + + return ( +
+
+

主题设置

+

+ 选择界面显示主题 +

+ +
+
+ {themeOptions.map((opt, index) => ( +
setTheme(opt.id)} + > +
+ {opt.icon} +
+
+
+ {opt.label} +
+
+ {opt.desc} +
+
+
+
+ ))} +
+
+ + {/* 当前状态提示 */} +
+ 当前模式: + + {theme === 'system' ? '跟随系统' : theme === 'dark' ? '深色模式' : '浅色模式'} + + {theme === 'system' && (实际显示:{isDark ? '深色' : '浅色'})} +
+
+
+ ); +} diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.css b/tauri-app/src/pages/VideoCreation/CoverDesign.css new file mode 100644 index 0000000..23f2f26 --- /dev/null +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.css @@ -0,0 +1,373 @@ +/** + * 封面制作页面样式 + * ================= + * + * 完全遵循字幕压制页面结构和间距,保持一致 + */ + +/* 布局 - 55:45 和字幕压制一致 */ +.step-layout.subtitle-burning { + grid-template-columns: 55fr 45fr; +} + +.step-layout.cover-design-variant { + grid-template-columns: 55fr 45fr; +} + +/* 左侧操作区 - 与视频生成页面统一 */ +.cover-design .step-panel-left { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + overflow-y: auto; + overflow-x: hidden; + padding-right: var(--spacing-lg); + height: 100%; +} + +/* panel-section - 和字幕压制完全一致 */ +.cover-design .panel-section { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +/* 标题输入区域 */ +.title-input-section { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.title-input-section .input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-base); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-card); + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.title-input-section .input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(54 178 106 / 10%); +} + +/* 模板标题区域头部 */ +.post-edit-template-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-sm); +} + +.post-edit-template-header .post-edit-section-title { + display: flex; + flex-direction: column; + gap: 2px; +} + +.panel-label { + font-size: var(--font-sm); + font-weight: 600; + color: var(--text-primary); +} + +.panel-sublabel { + font-size: var(--font-xs); + color: var(--text-tertiary); + font-weight: normal; +} + +/* 滑块分组 */ +.slider-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.slider-header { + display: flex; + justify-content: space-between; +} + +.slider-label { + font-size: var(--font-xs); + color: var(--text-secondary); +} + +/* 滑块输入样式 */ +.slider-input { + appearance: none; + width: 100%; + height: 6px; + background: transparent; + border-radius: var(--radius-sm); + outline: none; + cursor: pointer; + border: none; + padding: 0; +} + +.slider-input::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + background: linear-gradient( + to right, + var(--primary) 0%, + var(--primary) var(--slider-percent, 50%), + var(--bg-input) var(--slider-percent, 50%), + var(--bg-input) 100% + ); + border-radius: var(--radius-sm); +} + +.slider-input::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + background: var(--bg-card); + border: 2px solid var(--primary); + border-radius: var(--radius-full); + cursor: pointer; + box-shadow: 0 1px 4px rgb(0 0 0 / 15%); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; +} + +.slider-input::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 2px 8px rgb(54 178 106 / 30%); +} + +.slider-input:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 4px var(--primary-light); +} + +/* 标题模板网格 - 和字幕压制预设样式完全一致 */ +.cover-design .style-presets { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.preset-btn { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-card); + cursor: pointer; + transition: all var(--transition-fast); +} + +.preset-btn:hover { + border-color: var(--primary); + background: var(--bg-hover); +} + +.preset-btn.active { + border-color: var(--primary); + background: var(--primary-light); + color: var(--primary); +} + +.preset-preview { + font-size: var(--font-lg); + font-weight: bold; + line-height: 1; +} + +.preset-name { + font-size: var(--font-xs); + color: var(--text-secondary); +} + +.preset-btn.active .preset-name { + color: var(--primary); +} + +/* 字号区域 - 和字幕压制一致 */ +.font-size-section { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.font-size-value { + margin-left: var(--spacing-sm); + font-size: var(--font-sm); + color: var(--primary); + font-weight: 600; +} + +/* 封面图片选项 - 简洁双列卡片 */ +.cover-bg-options { + display: flex; + gap: var(--spacing-sm); +} + +.cover-bg-option { + flex: 1; + padding: var(--spacing-md) var(--spacing-sm); + border-radius: var(--radius-lg); + border: 1px solid var(--border-light); + background: var(--bg-card); + cursor: pointer; + text-align: center; + font-size: var(--font-sm); + color: var(--text-secondary); + transition: all var(--transition-fast); +} + +.cover-bg-option:hover { + border-color: var(--primary); + background: var(--bg-hover); + color: var(--primary); +} + +.cover-bg-option.selected { + border-color: var(--primary); + background: var(--primary-light); + color: var(--primary); + font-weight: 500; +} + +/* 提示文字 */ +.cover-hint { + margin-top: var(--spacing-sm); + font-size: var(--font-xs); + line-height: 1.5; +} + +.cover-hint-error { + color: #ef4444; +} + +.cover-hint-muted { + color: var(--text-tertiary); +} + +/* 生成按钮 */ +.cover-generate-btn { + width: 100%; + height: 48px; + font-size: var(--font-md); + font-weight: 600; + margin-top: var(--spacing-md); + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); +} + +/* 右侧预览区 - 与视频生成页面统一 */ +.video-gen-right { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + padding: 0; + overflow-y: auto; + align-items: center; + border-left: 1px solid var(--border-light); + padding-left: var(--spacing-lg); + min-height: 0; +} + +.video-preview-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.video-preview-container { + position: relative; + width: auto; + height: 600px; + aspect-ratio: 9 / 16; + margin: 0 auto; + background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%); + border-radius: var(--radius-xl); + overflow: hidden; + box-shadow: 0 8px 32px rgb(0 0 0 / 30%); + border: 1px solid var(--border-light); +} + +.video-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + gap: var(--spacing-sm); +} + +.video-placeholder .placeholder-sub { + font-size: var(--font-xs); + color: rgba(255, 255, 255, 0.4); +} + +/* 手机框预览 - 和字幕压制一致 */ +.post-edit-phone.cover-phone { + height: auto; + max-height: 580px; + aspect-ratio: 9 / 16; + width: auto; +} + +.preview-video { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 标题由 ASS.js 渲染在 canvas 层,无需手动 overlay */ + +/* 滚动条 */ +.cover-config-panel::-webkit-scrollbar { + width: 6px; +} + +.cover-config-panel::-webkit-scrollbar-track { + background: transparent; +} + +.cover-config-panel::-webkit-scrollbar-thumb { + background: var(--border-light); + border-radius: var(--radius-sm); +} + +.cover-config-panel::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* 封面标题文字层 — CSS 预览(替代 assjs) */ +.cover-title-overlay { + position: absolute; + left: 0; + width: 100%; + text-align: center; + font-family: 'DouyinSans', 'PingFang SC', 'Microsoft YaHei', sans-serif; + font-weight: bold; + line-height: 1.3; + pointer-events: none; + z-index: 10; + word-break: break-word; +} + +.cover-title-line { + white-space: nowrap; +} diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx new file mode 100644 index 0000000..5a37c0c --- /dev/null +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx @@ -0,0 +1,418 @@ +/** + * 封面制作页面 (Step 5) + * ================= + * + * 生成短视频封面:背景图 + 标题文字压制 + * 背景来源:1. 视频首帧 2. Kling Omni-Image AI 生成 + * 标题位置:中上,允许换行 + */ + +import { useState, useRef, useEffect, useMemo } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useProjectStore } from '../../store'; +import { getCurrentProjectId } from '../../api/modules/localStorage'; +import { useLocalVideo } from '../../hooks/useLocalVideo'; +import { generateCoverAss, saveAssFile, htmlColorToAss, cssColorToAss } from '../../utils/assGenerator'; +import { useProgressStore } from '../../store/progressStore'; +import './CoverDesign.css'; +import '../../components/Slider/Slider.css'; + + + +interface CoverStyle { + fontSize: number; + color: string; + strokeColor: string; + strokeWidth: number; + shadow?: number; // 阴影偏移量(0=无阴影) + backgroundColor?: string; // 背景色(存在时用 BorderStyle: 3 色块,忽略描边和阴影) +} + +// 预设样式(4个字命名,按实际效果) +const STYLE_PRESETS = [ + { id: 'white', name: '白字暗底', color: '#FFFFFF', strokeColor: '#000000', strokeWidth: 0, backgroundColor: 'rgba(0,0,0,0.5)' }, + { id: 'black', name: '白字黑边', color: '#FFFFFF', strokeColor: '#000000', strokeWidth: 3 }, + { id: 'yellow', name: '黄字黑边', color: '#FFD700', strokeColor: '#000000', strokeWidth: 3 }, + { id: 'neon', name: '黑字黄底', color: '#000000', strokeColor: '#000000', strokeWidth: 0, backgroundColor: '#FFD700' }, +]; + +const VIDEO_HEIGHT = 1920; +const MARGIN_V = 360; +const MARGIN_LR = 108; + +/** + * 用 CSS text-shadow 多层偏移模拟外侧描边(不侵蚀文字内部,比 -webkit-text-stroke 更接近 libass) + */ +function generateTextShadow(strokeColor: string, strokeWidthPx: number, shadowPx: number): string | undefined { + if (strokeWidthPx <= 0 && shadowPx <= 0) return undefined; + const shadows: string[] = []; + const w = Math.max(0, Math.round(strokeWidthPx)); + for (let r = 1; r <= w; r++) { + shadows.push(`${-r}px ${-r}px 0 ${strokeColor}`); + shadows.push(`${r}px ${-r}px 0 ${strokeColor}`); + shadows.push(`${-r}px ${r}px 0 ${strokeColor}`); + shadows.push(`${r}px ${r}px 0 ${strokeColor}`); + shadows.push(`${-r}px 0 0 ${strokeColor}`); + shadows.push(`${r}px 0 0 ${strokeColor}`); + shadows.push(`0 ${-r}px 0 ${strokeColor}`); + shadows.push(`0 ${r}px 0 ${strokeColor}`); + } + if (shadowPx > 0) { + const s = Math.round(shadowPx); + shadows.push(`${s}px ${s}px ${s}px ${strokeColor}`); + } + return shadows.join(', '); +} + +/** + * 按最大字符数换行(中文场景),同时保留用户手动输入的换行 + */ +function wrapText(text: string, maxChars: number): string { + if (!text || maxChars <= 0) { + return text; + } + // 先按用户手动换行拆分成段落 + const paragraphs = text.split('\n'); + const wrappedParagraphs = paragraphs.map((paragraph) => { + const lines: string[] = []; + let current = ''; + for (const char of paragraph) { + if (current.length >= maxChars) { + lines.push(current); + current = char; + } else { + current += char; + } + } + if (current) { + lines.push(current); + } + return lines.join('\\N'); + }); + return wrappedParagraphs.join('\\N'); +} + +export default function CoverDesign() { + const segments = useProjectStore(state => state.segments); + const topic = useProjectStore(state => state.topic); + const coverConfig = useProjectStore(state => state.coverConfig); + const setCoverPath = useProjectStore(state => state.setCoverPath); + const setCoverConfig = useProjectStore(state => state.setCoverConfig); + const projectId = getCurrentProjectId(); + + const [caption, setCaption] = useState(topic || ''); + const [coverStyle, setCoverStyle] = useState({ + fontSize: 72, + color: '#FFFFFF', + strokeColor: '#000000', + strokeWidth: 2, + }); + const [selectedPreset, setSelectedPreset] = useState('white'); + + // 防抖后的样式值 — 用于预览渲染,避免滑块拖动时频繁更新 + const [debouncedCoverStyle, setDebouncedCoverStyle] = useState(coverStyle); + useEffect(() => { + const timer = setTimeout(() => setDebouncedCoverStyle(coverStyle), 150); + return () => clearTimeout(timer); + }, [coverStyle]); + + // 加载之前保存的封面配置 + useEffect(() => { + if (!coverConfig) return; + if (coverConfig.caption !== undefined) setCaption(coverConfig.caption); + if (coverConfig.coverStyle !== undefined) setCoverStyle(coverConfig.coverStyle); + if (coverConfig.selectedPreset !== undefined) setSelectedPreset(coverConfig.selectedPreset); + }, [coverConfig]); + + const videoRef = useRef(null); + const containerRef = useRef(null); + + // 预览缩放比例:与字幕模板一致,按视频预览容器高度 600px / 视频基准高度 1920px + // 字幕模板由 assjs 内部自动按视频高度缩放,这里手动对齐同一比例 + const previewScale = 600 / VIDEO_HEIGHT; + + // 固定取镜头1的视频路径作为首帧来源 + const shot1 = segments.find(s => Number(s.id) === 1); + const shot1VideoPath = shot1?.burnedVideoPath || shot1?.videoPath; + // 使用 useLocalVideo hook 加载本地视频(解决 asset:// 403 问题) + const { videoUrl: firstFrameVideoUrl } = useLocalVideo(shot1VideoPath); + + const applyPreset = (presetId: string) => { + const preset = STYLE_PRESETS.find(p => p.id === presetId); + if (!preset) { + return; + } + setSelectedPreset(presetId); + setCoverStyle({ + ...coverStyle, + color: preset.color, + strokeColor: preset.strokeColor, + strokeWidth: preset.strokeWidth, + shadow: (preset as any).shadow, + backgroundColor: (preset as any).backgroundColor, + }); + }; + + /** + * 获取背景图片本地路径(固定使用视频首帧) + */ + const getBackgroundImagePath = async (): Promise => { + if (!shot1VideoPath) { + throw new Error('镜头1没有可用视频,无法提取首帧'); + } + const outputResult = await invoke<{ code: number; data?: string; message: string }>('get_image_save_path', { + projectId, + filename: `cover_first_frame_${Date.now()}.jpg`, + }); + if (outputResult.code !== 200 || !outputResult.data) { + throw new Error(outputResult.message); + } + const extractResult = await invoke<{ code: number; data?: string; message: string }>('extract_video_first_frame', { + request: { + video_path: shot1VideoPath, + output_path: outputResult.data, + }, + }); + if (extractResult.code !== 200 || !extractResult.data) { + throw new Error(extractResult.message); + } + return extractResult.data; + }; + + const handleGenerate = async () => { + if (!projectId) { + return; + } + if (!caption.trim()) { + return; + } + + useProgressStore.getState().show('封面生成'); + + try { + // 1. 获取背景图片(视频首帧) + useProgressStore.getState().update('正在提取视频首帧...'); + const bgImagePath = await getBackgroundImagePath(); + + // 2. 生成 ASS 文件并压制标题字幕 + useProgressStore.getState().update('正在合成封面...'); + const maxCharsPerLine = Math.max(3, Math.floor(864 / coverStyle.fontSize)); + const wrappedText = wrapText(caption.trim(), maxCharsPerLine); + + const isBackgroundStyle = !!coverStyle.backgroundColor; + // BorderStyle: 3 的背景框大小由 outline 决定,需要 outline > 0 才能显示背景 + // outlineColor 和 backColor 设为相同颜色,让背景框和背景融合为统一色块 + const bgOutline = isBackgroundStyle ? 40 : coverStyle.strokeWidth; + const bgAssColor = isBackgroundStyle ? cssColorToAss(coverStyle.backgroundColor!) : '&H00000000'; + const assContent = generateCoverAss(wrappedText, { + fontSize: coverStyle.fontSize, + outline: bgOutline, + shadow: isBackgroundStyle ? 0 : (coverStyle.shadow || 0), + primaryColor: htmlColorToAss(coverStyle.color), + outlineColor: isBackgroundStyle ? bgAssColor : htmlColorToAss(coverStyle.strokeColor), + backColor: bgAssColor, + borderStyle: isBackgroundStyle ? 3 : 1, + alignment: 8, + marginV: MARGIN_V, + marginL: MARGIN_LR, + marginR: MARGIN_LR, + }); + + const assFilename = `cover_title_${Date.now()}.ass`; + const assPath = await saveAssFile(projectId, assFilename, assContent); + + // 确定输出路径(images 目录) + const outputResult = await invoke<{ code: number; data?: string; message: string }>('get_image_save_path', { + projectId, + filename: `cover_${Date.now()}.png`, + }); + if (outputResult.code !== 200 || !outputResult.data) { + throw new Error(outputResult.message); + } + const coverOutputPath = outputResult.data; + + // FFmpeg 压制封面 + const coverResult = await invoke<{ code: number; data?: string; message: string }>('generate_cover_image', { + request: { + image_path: bgImagePath, + ass_path: assPath, + output_path: coverOutputPath, + }, + }); + if (coverResult.code !== 200 || !coverResult.data) { + throw new Error(coverResult.message); + } + + // 3. 更新状态 + setCoverPath(coverOutputPath); + + // 4. 快照封面配置到 store(触发 saveMetaToLocalFile) + setCoverConfig({ + caption: caption.trim(), + coverStyle, + selectedPreset, + }); + + useProgressStore.getState().success('封面生成完成'); + } catch (error: any) { + const message = error?.message || String(error) || '封面生成失败'; + console.error('封面生成失败:', error); + useProgressStore.getState().error(message); + } + }; + + // ===== CSS 预览计算 ===== + const previewFontSize = debouncedCoverStyle.fontSize * previewScale; + const previewShadow = (debouncedCoverStyle.shadow || 0) * previewScale; + const previewMarginTop = MARGIN_V * previewScale; + const previewMarginH = MARGIN_LR * previewScale; + + const previewLines = useMemo(() => { + if (!caption.trim()) return []; + const maxCharsPerLine = Math.max(3, Math.floor(864 / debouncedCoverStyle.fontSize)); + const wrappedText = wrapText(caption.trim(), maxCharsPerLine); + return wrappedText.split('\\N'); + }, [caption, debouncedCoverStyle.fontSize]); + + const hasPreview = !!firstFrameVideoUrl && !!caption.trim() && previewLines.length > 0; + + return ( +
+ {/* 左侧:配置区域 */} +
+
+ {/* 标题文案输入 */} +
+ +