feat: init

This commit is contained in:
lk-eternal 2025-08-31 18:38:41 +08:00
commit 70d247fa4c
43 changed files with 6829 additions and 0 deletions

54
.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# 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
MANIFEST
# Virtual Environment
venv/
env/
ENV/
# Environment variables
.env
.env.local
.env.production
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
logs/
# Database
*.db
*.sqlite3
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp

442
GUIDE_FOR_AI.md Normal file
View File

@ -0,0 +1,442 @@
**角色 (Persona):**
你是一位资深的Python后端开发专家精通使用FastAPI进行快速开发并且在领域驱动设计DDD和构建可扩展、可维护的AI应用方面拥有丰富的实践经验。
**任务 (Objective):**
根据以下详细规格说明为我创建一个完整、可运行的Python后端项目。该项目是一个模块化的AI视频 storyboard分镜生成器包含独立的**提示词管理模块**和**项目管理模块**。
-----
### **1. 核心架构与技术要求**
* **编程语言:** Python 3.10+
* **Web框架:** FastAPI
* **架构模式:** 严格遵循领域驱动设计DDD分层架构。
* **数据库与ORM:** PostgreSQL使用SQLAlchemy 2.0。
* **数据库迁移:** 使用 Alembic。
* **依赖管理:** 使用 `requirements.txt` 文件。
* **配置管理:** 使用 `.env` 文件和 `pydantic-settings`
* **代码质量:** PEP 8规范, 类型提示, 健壮的错误处理, 结构化日志。
### **2. 技术栈 (Technology Stack)**
请在 `requirements.txt` 文件中包含以下核心依赖:
```txt
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
alembic
pydantic
pydantic-settings
python-dotenv
google-generativeai
qiniu
openai # OpenRouter使用与OpenAI兼容的SDK
loguru
httpx # 用于异步下载图片
```
### **3. 环境变量配置**
在项目根目录创建 `.env.example` 文件:
```ini
# Database
DATABASE_URL="postgresql://user:password@host:port/dbname"
# Qiniu Cloud
QINIU_ACCESS_KEY="your_qiniu_access_key"
QINIU_SECRET_KEY="your_qiniu_secret_key"
QINIU_BUCKET_NAME="your_bucket_name"
QINIU_DOMAIN="your_qiniu_cdn_domain"
# AI Models
GOOGLE_API_KEY="your_google_ai_api_key"
OPENROUTER_API_KEY="your_openrouter_api_key"
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
```
### **4. 项目结构**
请严格按照以下模块化的结构生成项目文件和目录:
```
.
├── alembic/
├── src/
│ ├── api/
│ │ ├── dependencies.py
│ │ └── routes/
│ │ ├── prompts.py # 提示词模块路由
│ │ ├── qiniu.py
│ │ └── projects.py # 项目、素材、分镜模块路由
│ ├── application/
│ │ ├── schemas/
│ │ │ ├── prompt.py
│ │ │ ├── project.py
│ │ │ ├── asset.py
│ │ │ └── storyboard.py
│ │ └── use_cases/
│ │ ├── prompt_use_cases.py
│ │ └── project_use_cases.py # 包含项目、素材、分镜的用例
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── prompt.py
│ │ │ ├── project.py
│ │ │ ├── asset.py
│ │ │ └── storyboard.py
│ │ ├── repositories/
│ │ │ ├── prompt_repository.py
│ │ │ └── project_repository.py # 统一管理项目聚合根下的所有实体
│ │ └── services/
│ │ ├── ai_service.py
│ │ └── storage_service.py
│ ├── infrastructure/
│ │ ├── database/
│ │ │ ├── models.py
│ │ │ ├── repository_impl/
│ │ │ │ ├── prompt_repository_impl.py
│ │ │ │ └── project_repository_impl.py
│ │ │ └── session.py
│ │ ├── external/
│ │ │ ├── gemini_client.py
│ │ │ ├── openrouter_client.py
│ │ │ └── qiniu_client.py
│ │ └── config.py
│ └── main.py
├── .env
├── .env.example
├── .gitignore
├── alembic.ini
└── requirements.txt
```
### **5. 数据库表结构**
* **基础模型:** 所有模型继承一个基础类,自动包含 `id (int, primary_key)`, `created_at (datetime)`, `updated_at (datetime)` 字段。
* **`prompts` 表:**
* `step (str, index)`: 描述提示词在哪个阶段使用,例如 "create\_shots"。
* `name (str, index)`: 提示词的名称。
* `prompt (text)`: 提示词的具体内容。
* **约束:** `step``name` 构成联合唯一约束。
* **`projects` 表 (项目表):**
* `name (str)`: 项目名。
* `script (text)`: 剧本内容。
* `full_script (text)`: 完整剧本内容。
* `prompt_used (json)`: 生成分镜时使用的提示词快照。
* **`assets` 表 (素材表):**
* `project_id (int, ForeignKey("projects.id"))`: 关联的项目ID。
* `name (str)`: 素材名。
* `description (text)`: 素材描述。
* `tags (ARRAY(String))`: 素材标签数组。
* `original_url (str)`: 素材原始URL。
* **`storyboards` 表 (分镜表):**
* `project_id (int, ForeignKey("projects.id"))`: 关联的项目ID。
* `frame_prompt (text)`: 首帧提示词。
* `frame_asset_ids (ARRAY(Integer))`: 用于生成首帧图的素材ID数组。
* `frame_image_url (str, nullable=True)`: 生成的首帧图URL。
* `shot_prompt (text)`: 分镜动态内容提示词。
* `shot_video_url (str, nullable=True)`: 生成的分镜视频URL。
### **6. API 端点功能规格**
获取七牛云上传Token
路径: /api/v1/qiniu/upload-token
文件: src/api/routes/qiniu.py
方法: POST
成功响应 (200 OK): { "token": "...", "domain": "...", "bucket": "..." }
核心实现逻辑:
从环境变量中读取七牛云的 access_key, secret_key, bucket_name, domain。
使用七牛云SDK生成上传token。
返回token、domain和bucket name。
参考提供的 get_qiniu_upload_token 函数实现,并做好异常处理。
-----
#### **模块一:提示词管理 (Prompt Management)**
* **文件:** `src/api/routes/prompts.py`
<!-- end list -->
1. **查询所有提示词 (GET /api/v1/prompts)**
* **成功响应 (200 OK):** `[ { "step": "...", "name": "...", "prompt": "..." } ]`
* **核心实现逻辑:** 从数据库查询并返回所有提示词记录。
2. **新增或修改提示词 (POST /api/v1/prompts)**
* **请求体:** `{ "step": "create_shots", "name": "default_v1", "prompt": "..." }`
* **成功响应 (200 OK):** `{ "id": 1, "step": "...", "name": "...", "prompt": "..." }`
* **核心实现逻辑:** 根据 `step``name` 查找记录。如果存在,则更新 `prompt`;如果不存在,则创建新记录。
-----
#### **模块二:项目与视频生成 (Project & Video Generation)**
* **文件:** `src/api/routes/projects.py`
<!-- end list -->
1. **分页获取项目列表 (GET /api/v1/projects)**
* **查询参数:** `?page=1&size=20`
* **成功响应 (200 OK):** `{ "items": [ { "id": 1, "name": "...", "created_at": "...", "updated_at": "..." } ], "total": 100, "page": 1, "size": 20 }`
* **核心实现逻辑:** 实现数据库分页查询。
2. **新建项目 (POST /api/v1/projects)**
* **请求体:** `{ "name": "我的第一个项目" }`
* **成功响应 (201 OK):** `{ "id": 1, "name": "我的第一个项目" }`
* **核心实现逻辑:** 在 `projects` 表中创建一条新记录。
3. **生成剧本 (POST /api/v1/projects/{project\_id}/generate-script)**
* **请求体:** `{ "script_or_idea": "一个关于小猫冒险的故事..." }`
* **成功响应 (200 OK):** `{ "full_script": "...", "assets": [ { "id": 1, "name": "小猫", "description": "一只可爱的橘猫", "tags": ["动物", "主角"] } ] }`
* **核心实现逻辑:**
1. 接收 `project_id` 和剧本或创意内容。
2. 调用大模型生成完整剧本和所需素材信息(素材名、描述、标签)。
3. 将完整剧本更新到 `projects` 表的 `full_script` 字段。
4. 将素材信息批量存入 `assets` 表,关联 `project_id`
5. 返回生成的完整剧本和素材列表。
4. **更新素材图 (PUT /api/v1/assets/{asset\_id}/image)**
* **请求体:** `{ "image_url": "https://example.com/image.jpg" }`
* **成功响应 (200 OK):** `{ "message": "素材图片更新成功" }`
* **核心实现逻辑:**
1. 接收 `asset_id` 和素材图片URL。
2. 更新 `assets` 表中对应记录的 `original_url` 字段。
3. 返回成功响应。
5. **创建素材图 (POST /api/v1/projects/{project\_id}/generate-asset-images)**
* **请求体:** 无
* **成功响应 (200 OK):** `[ { "id": 1, "name": "小猫", "description": "...", "tags": [], "original_url": "https://..." } ]`
* **核心实现逻辑:**
1. 接收 `project_id`
2. 查询该项目下所有没有 `original_url` 的素材。
3. 对于每个无图片的素材,使用 `gemini-2.5-flash-image-preview` 生成图片。
4. 将生成的图片上传到七牛云,更新 `original_url` 字段。
5. 返回所有素材信息列表。
6. **上传并分析素材 (POST /api/v1/projects/{project\_id}/assets)** [保留但不推荐使用]
* **请求体:** `{ "asset_urls": ["url1.jpg", "url2.png"] }`
* **成功响应 (200 OK):** `[ { "id": 1, "name": "...", "description": "...", "tags": ["...", "..."] } ]`
* **核心实现逻辑:**
1. 接收 `project_id` 和素材URL列表。
2. 对于每个URL异步下载图片。
3. 使用大模型如Gemini分析图片要求返回结构化JSON`{ "name": "...", "description": "...", "tags": ["..."] }`
4. 将分析结果连同 `project_id``original_url` 存入 `assets` 表。
5. 返回新创建的素材记录列表。
7. **生成分镜 (POST /api/v1/projects/{project\_id}/generate-storyboards)**
* **请求体:** `{ "script": "...", "prompt_step": "create_shots", "prompt_name": "default_v1" }`
* **成功响应 (200 OK):** `[ { "id": 1, "frame_prompt": "...", "frame_asset_ids": [1, 3], "shot_prompt": "..." } ]`
* **核心实现逻辑:**
1. **检查所有素材是否都有图片**:确保该项目下所有素材的 `original_url` 字段都不为空。
2. 根据 `prompt_step``prompt_name` 从数据库获取提示词。
3. 获取该项目 (`project_id`) 下所有素材的JSON描述。
4. 将 **系统提示词**、**所有素材的JSON描述** 和 **用户剧本** 组合成一个完整的Prompt。
5. 调用OpenRouter大模型要求返回结构化JSON数组每项包含 `frame_prompt`, `frame_asset_ids`, `shot_prompt`
6. 将返回的数组解析并批量存入 `storyboards` 表,关联 `project_id`
7. 返回新创建的分镜记录列表。
8. **生成分镜首帧图 (POST /api/v1/storyboards/{storyboard\_id}/generate-frame)**
* **请求体:** `{ "frame_prompt": "...", "frame_asset_ids": [1, 2] }`
* **成功响应 (200 OK):** `{ "frame_image_url": "https://..." }`
* **核心实现逻辑:**
1. 接收 `storyboard_id` 和可编辑的 `frame_prompt`, `frame_asset_ids`
2. 根据 `frame_asset_ids` 从数据库获取对应素材的原始URL并下载图片。
3. 调用 `gemini-2.5-flash-image-preview` 模型生成图片。
4. 上传生成的图片到七牛云。
5. 更新 `storyboards` 表中对应记录的 `frame_prompt`, `frame_asset_ids`, 和 `frame_image_url` 字段。
6. 返回生成的图片URL。
9. **首帧图指导修改 (POST /api/v1/storyboards/{storyboard\_id}/guide-frame)**
* **请求体:** `{ "guide_image_url": "https://example.com/guide.jpg" }`
* **成功响应 (200 OK):** `{ "frame_image_url": "https://..." }`
* **核心实现逻辑:**
1. 接收 `storyboard_id` 和指导图URL。
2. 从数据库获取该分镜的原首帧图URL并下载。
3. 下载指导图。
4. 使用 `gemini-2.5-flash-image-preview` 模型,结合原首帧图和指导图生成新的首帧图。
5. 上传生成的图片到七牛云。
6. 更新 `storyboards` 表中对应记录的 `frame_image_url` 字段(覆盖原首帧图)。
7. 返回新生成的图片URL。
10. **生成分镜视频 (POST /api/v1/storyboards/{storyboard\_id}/generate-video)**
* **请求体:** `{ "shot_prompt": "..." }`
* **成功响应 (200 OK):** `{ "shot_video_url": "https://..." }`
* **核心实现逻辑:**
1. 接收 `storyboard_id` 和可编辑的 `shot_prompt`
2. 从数据库获取该分镜的 `frame_image_url` 并下载图片。
3. 调用 `veo-3.0-generate-preview` 模型生成视频(需要轮询等待)。
4. 上传生成的视频到七牛云。
5. 更新 `storyboards` 表中对应记录的 `shot_prompt``shot_video_url` 字段。
6. 返回生成的视频URL。
11. **获取项目所有信息 (GET /api/v1/projects/{project\_id})**
* **成功响应 (200 OK):**
```json
{
"project": { "id": 1, "name": "...", "created_at": "...", "updated_at": "..." },
"assets": [ { "id": 1, "name": "...", "description": "...", "tags": [] } ],
"storyboards": [ { "id": 1, "frame_prompt": "...", "frame_asset_ids": [], "frame_image_url": "...", "shot_prompt": "...", "shot_video_url": "..." } ]
}
```
* **核心实现逻辑:** 根据 `project_id`,联表查询或分别查询项目、素材、分镜的所有相关信息并组合返回。
-----
**执行指令 (Execution Instruction):**
请从 `requirements.txt` 文件开始,然后是 `.env.example` 和配置文件,接着按照 `infrastructure`, `domain`, `application`, `api` 的顺序,逐一生成每个文件的完整代码。确保代码是完整、可用且符合上述所有要求的。
相关技术参考:
1.对接gemini-2.5-flash-image-preview图片生成模型,API参考地址https://ai.google.dev/gemini-api/docs/image-generation?hl=zh-cn
官方示例:
from google import genai
from google.genai import types
from PIL import Image
from io import BytesIO
client = genai.Client()
# Base image prompts:
# 1. Dress: "A professionally shot photo of a blue floral summer dress on a plain white background, ghost mannequin style."
# 2. Model: "Full-body shot of a woman with her hair in a bun, smiling, standing against a neutral grey studio background."
dress_image = Image.open('/path/to/your/dress.png')
model_image = Image.open('/path/to/your/model.png')
text_input = """Create a professional e-commerce fashion photo. Take the blue floral dress from the first image and let the woman from the second image wear it. Generate a realistic, full-body shot of the woman wearing the dress, with the lighting and shadows adjusted to match the outdoor environment."""
# Generate an image from a text prompt
response = client.models.generate_content(
model="gemini-2.5-flash-image-preview",
contents=[dress_image, model_image, text_input],
)
image_parts = [
part.inline_data.data
for part in response.candidates[0].content.parts
if part.inline_data
]
if image_parts:
image = Image.open(BytesIO(image_parts[0]))
image.save('fashion_ecommerce_shot.png')
image.show()
2.对接veo3生成视频模型,API参考地址:https://ai.google.dev/gemini-api/docs/video?hl=zh-cn
参考实现:
import time
from google import genai
client = genai.Client()
prompt = "Panning wide shot of a calico kitten sleeping in the sunshine"
image = #base64
operation = client.models.generate_videos(
model="veo-3.0-generate-preview",
prompt=prompt,
image=image,
)
# Poll the operation status until the video is ready.
while not operation.done:
print("Waiting for video generation to complete...")
time.sleep(10)
operation = client.operations.get(operation)
# Download the video.
video = operation.response.generated_videos[0]
client.files.download(file=video.video)
video.video.save("veo3_with_image_input.mp4")
print("Generated video saved to veo3_with_image_input.mp4")
3.对接七牛云,向前端提供获取token的接口,参考:
@router.post("/upload-token")
def get_upload_token():
"""
获取七牛云上传token
Returns:
上传token和域名信息
"""
try:
# 获取上传token
token = get_qiniu_upload_token()
if not token:
raise HTTPException(status_code=500, detail="获取上传token失败")
# 返回token和域名信息
return {
"token": token,
"domain": f"https://{qiniu_domain}",
"bucket": qiniu_bucket_name
}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取上传token异常: {e}")
raise HTTPException(status_code=500, detail=f"获取上传token失败: {str(e)}")
def get_qiniu_upload_token() -> str:
"""
获取七牛云上传token
Returns:
上传token
"""
try:
q = qiniu.Auth(qiniu_access_key, qiniu_secret_key)
token = q.upload_token(qiniu_bucket_name)
return token
except Exception as e:
logger.error(f"获取七牛云上传token失败: {e}")
return None
def upload_file_to_qiniu(file_bytes: bytes, suffix: str) -> str:
"""
上传文件到七牛云
Args:
file_bytes: 文件二进制数据
filename: 文件名
Returns:
文件 URL
:param suffix: 文件扩展名
"""
try:
q = qiniu.Auth(qiniu_access_key, qiniu_secret_key)
token = q.upload_token(qiniu_bucket_name)
filename = f"{int(time.time())}.{suffix}"
ret, info = qiniu.put_data( up_token=token, key=filename, data=file_bytes)
if ret is None:
logger.error(f"上传到七牛云失败: {info}")
return None
return f"https://{qiniu_domain}/{filename}"
except Exception as e:
logger.error(f"上传到七牛云失败: {e}")
return None

212
README.md Normal file
View File

@ -0,0 +1,212 @@
# AI视频分镜生成器
基于FastAPI和领域驱动设计DDD的AI视频分镜生成系统支持提示词管理、项目管理、素材分析、分镜生成等功能。
## 功能特性
- **提示词管理**: 支持创建、更新、删除提示词模板
- **项目管理**: 完整的项目生命周期管理
- **素材分析**: 使用AI自动分析图片素材内容
- **分镜生成**: 基于AI生成视频分镜内容
- **图片生成**: 使用Gemini生成分镜首帧图
- **视频生成**: 支持生成分镜视频需要实现具体API
- **文件存储**: 集成七牛云存储服务
## 技术架构
- **编程语言**: Python 3.10+
- **Web框架**: FastAPI
- **架构模式**: 领域驱动设计DDD分层架构
- **数据库**: PostgreSQL + SQLAlchemy 2.0
- **数据库迁移**: Alembic
- **AI服务**: Google Gemini + OpenRouter
- **存储服务**: 七牛云
## 项目结构
```
.
├── alembic/ # 数据库迁移
├── src/
│ ├── api/ # API层
│ │ ├── dependencies.py # 依赖注入
│ │ └── routes/ # 路由定义
│ ├── application/ # 应用层
│ │ ├── schemas/ # 数据传输对象
│ │ └── use_cases/ # 应用用例
│ ├── domain/ # 领域层
│ │ ├── entities/ # 领域实体
│ │ ├── repositories/ # 仓储接口
│ │ └── services/ # 领域服务
│ ├── infrastructure/ # 基础设施层
│ │ ├── database/ # 数据库相关
│ │ └── external/ # 外部服务客户端
│ └── main.py # 应用入口
├── .env.example # 环境变量示例
├── requirements.txt # 项目依赖
└── README.md # 项目说明
```
## 快速开始
### 1. 环境准备
- Python 3.10+
- PostgreSQL 数据库
- 七牛云账号
- Google AI API Key
- OpenRouter API Key
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 环境配置
复制 `.env.example``.env` 并配置相关参数:
```bash
cp .env.example .env
```
编辑 `.env` 文件,配置以下参数:
```ini
# 数据库配置
DATABASE_URL="postgresql://user:password@host:port/dbname"
# 七牛云配置
QINIU_ACCESS_KEY="your_qiniu_access_key"
QINIU_SECRET_KEY="your_qiniu_secret_key"
QINIU_BUCKET_NAME="your_qiniu_bucket_name"
QINIU_DOMAIN="your_qiniu_cdn_domain"
# AI模型配置
GOOGLE_API_KEY="your_google_ai_api_key"
OPENROUTER_API_KEY="your_openrouter_api_key"
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
```
### 4. 数据库迁移
```bash
# 初始化迁移
alembic init alembic
# 生成迁移文件
alembic revision --autogenerate -m "Initial migration"
# 执行迁移
alembic upgrade head
```
### 5. 启动应用
```bash
# 开发模式启动
python -m uvicorn src.main:app --reload --host 0.0.0.0 --port 8000
# 或者直接运行
python src/main.py
```
### 6. 访问API文档
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API端点
### 提示词管理
- `GET /api/v1/prompts` - 获取所有提示词
- `POST /api/v1/prompts` - 创建或更新提示词
- `DELETE /api/v1/prompts/{prompt_id}` - 删除提示词
### 项目管理
- `GET /api/v1/projects` - 分页获取项目列表
- `POST /api/v1/projects` - 创建新项目
- `GET /api/v1/projects/{project_id}` - 获取项目详情
### 素材管理
- `POST /api/v1/projects/{project_id}/assets` - 上传并分析素材
### 分镜生成
- `POST /api/v1/projects/{project_id}/generate-storyboards` - 生成分镜
- `POST /api/v1/storyboards/{storyboard_id}/generate-frame` - 生成首帧图
- `POST /api/v1/storyboards/{storyboard_id}/generate-video` - 生成视频
### 文件上传
- `POST /api/v1/qiniu/upload-token` - 获取七牛云上传Token
## 开发指南
### 添加新的领域实体
1. 在 `src/domain/entities/` 中创建实体类
2. 在 `src/infrastructure/database/models.py` 中添加数据库模型
3. 在 `src/domain/repositories/` 中定义仓储接口
4. 在 `src/infrastructure/database/repository_impl/` 中实现仓储
5. 在 `src/application/schemas/` 中添加数据传输对象
6. 在 `src/application/use_cases/` 中添加应用用例
7. 在 `src/api/routes/` 中添加API路由
### 数据库迁移
```bash
# 生成迁移文件
alembic revision --autogenerate -m "描述变更内容"
# 执行迁移
alembic upgrade head
# 回滚迁移
alembic downgrade -1
```
## 部署
### 生产环境配置
1. 修改 `alembic.ini` 中的数据库URL
2. 配置生产环境的 `.env` 文件
3. 使用生产级别的WSGI服务器如Gunicorn
4. 配置反向代理如Nginx
5. 设置日志轮转和监控
### Docker部署
```dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 联系方式
如有问题或建议,请提交 Issue 或联系项目维护者。

112
alembic.ini Normal file
View File

@ -0,0 +1,112 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = 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
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# 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 number format
version_num_format = %%04d
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
# behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_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
sqlalchemy.url = postgresql://user:password@host:port/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 exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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

94
alembic/env.py Normal file
View File

@ -0,0 +1,94 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# 添加项目根目录到Python路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 导入数据库模型
from src.infrastructure.database.models import Base
from src.infrastructure.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_url():
"""获取数据库URL"""
return settings.database_url
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_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.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
if configuration is None:
configuration = {}
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
configuration,
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()

24
alembic/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,28 @@
"""add_full_script_to_projects
Revision ID: 0dbf67c5a82b
Revises: ba71bc5acd84
Create Date: 2025-08-29 10:23:11.757401
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0dbf67c5a82b'
down_revision = 'ba71bc5acd84'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('projects', sa.Column('full_script', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('projects', 'full_script')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""update_asset_original_url_nullable
Revision ID: 2a45d85dda67
Revises: 0dbf67c5a82b
Create Date: 2025-08-29 10:30:19.182245
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2a45d85dda67'
down_revision = '0dbf67c5a82b'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('assets', 'original_url',
existing_type=mysql.VARCHAR(length=500),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('assets', 'original_url',
existing_type=mysql.VARCHAR(length=500),
nullable=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add video url
Revision ID: 588220abccaf
Revises: 9957de01a152
Create Date: 2025-08-29 15:04:20.580590
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '588220abccaf'
down_revision = '9957de01a152'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('projects', sa.Column('video_url', sa.String(length=500), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('projects', 'video_url')
# ### end Alembic commands ###

View File

@ -0,0 +1,42 @@
"""add soft delete
Revision ID: 9957de01a152
Revises: 2a45d85dda67
Create Date: 2025-08-29 10:53:33.843499
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9957de01a152'
down_revision = '2a45d85dda67'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('assets', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_assets_deleted_at'), 'assets', ['deleted_at'], unique=False)
op.add_column('projects', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_projects_deleted_at'), 'projects', ['deleted_at'], unique=False)
op.add_column('prompts', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_prompts_deleted_at'), 'prompts', ['deleted_at'], unique=False)
op.add_column('storyboards', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_storyboards_deleted_at'), 'storyboards', ['deleted_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_storyboards_deleted_at'), table_name='storyboards')
op.drop_column('storyboards', 'deleted_at')
op.drop_index(op.f('ix_prompts_deleted_at'), table_name='prompts')
op.drop_column('prompts', 'deleted_at')
op.drop_index(op.f('ix_projects_deleted_at'), table_name='projects')
op.drop_column('projects', 'deleted_at')
op.drop_index(op.f('ix_assets_deleted_at'), table_name='assets')
op.drop_column('assets', 'deleted_at')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""Update project prompt
Revision ID: ba71bc5acd84
Revises:
Create Date: 2025-08-28 13:54:30.453251
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'ba71bc5acd84'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('projects', sa.Column('prompt', sa.Text(), nullable=True))
op.drop_column('projects', 'prompt_used')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('projects', sa.Column('prompt_used', mysql.JSON(), nullable=True))
op.drop_column('projects', 'prompt')
# ### end Alembic commands ###

13
env.example Normal file
View File

@ -0,0 +1,13 @@
# Database
DATABASE_URL="postgresql://user:password@host:port/dbname"
# Qiniu Cloud
QINIU_ACCESS_KEY="your_qiniu_access_key"
QINIU_SECRET_KEY="your_qiniu_secret_key"
QINIU_BUCKET_NAME="your_qiniu_bucket_name"
QINIU_DOMAIN="your_qiniu_cdn_domain"
# AI Models
GOOGLE_API_KEY="your_google_ai_api_key"
OPENROUTER_API_KEY="your_openrouter_api_key"
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"

850
frontend.md Normal file
View File

@ -0,0 +1,850 @@
# **AI视频分镜生成器 - 前端API接口文档 (最终版)**
## **基本信息**
* **Base URL:** `/api/v1`
* **Content-Type:** `application/json`
## **重要功能更新 - 自定义提示词模板**
**新增功能:** 现在支持自定义提示词模板!前端可以:
1. 让用户从预设模板中选择
2. 允许用户微调提示词内容
3. 将完整的提示词模板传递给后端
**约定变量格式:**
- 生成剧本:`${script_or_idea}` - 用户的剧本或创意内容
- 生成分镜:`${materials_description}` - 项目素材描述,`${script}` - 用户剧本
**验证机制:** 后端会自动验证提示词模板是否包含必需的变量,缺少时会返回明确的错误提示。
## **新的工作流程说明**
本次更新引入了全新的AI驱动工作流程推荐按以下顺序进行
### **推荐工作流程:**
1. **创建项目**`POST /projects`
2. **生成剧本**`POST /projects/{project_id}/generate-script`
- 输入创意或剧本草稿
- AI自动生成完整剧本和所需素材列表
3. **素材处理** (三选一)
- **选项A** 批量生成所有素材图 → `POST /projects/{project_id}/generate-asset-images`
- **选项B** 单独生成指定素材图 → `POST /assets/{asset_id}/generate-image`
- **选项C** 手动为素材提供图片 → `PUT /assets/{asset_id}/image`
4. **生成分镜**`POST /projects/{project_id}/generate-storyboards`
- 系统会检查所有素材是否都有图片
5. **生成首帧图**`POST /storyboards/{storyboard_id}/generate-frame`
6. **(可选)首帧图指导修改** → `POST /storyboards/{storyboard_id}/guide-frame`
7. **生成分镜视频**`POST /storyboards/{storyboard_id}/generate-video`
8. **合成项目视频**`POST /projects/{project_id}/compose-video`
### **兼容性说明:**
- 旧的"关联素材"接口 (`POST /projects/{project_id}/assets`) 仍然保留,但不推荐使用
- 项目删除现在是软删除,不会真正删除数据
## **模块:公共服务 (Public Services)**
### **1. 获取七牛云上传Token**
获取用于客户端直传文件到七牛云存储的临时Token。
* **Endpoint:** `GET /qiniu/upload-token`
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Object`
* **描述:** 返回上传所需的Token以及相关的存储桶信息。Token具有一定的有效期。
<!-- end list -->
```json
{
"token": "qiniu_generated_upload_token_string",
"domain": "https://your-qiniu-domain.com",
"bucket": "your_bucket_name"
}
```
-----
## **模块一:提示词管理 (Prompt Management)**
### **1. 查询所有提示词**
获取系统中存储的所有可用提示词列表。
* **Endpoint:** `GET /prompts`
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Array<Object>`
<!-- end list -->
```json
[
{
"step": "create_shots",
"name": "default_v1",
"prompt": "这是一个用于生成分镜的提示词..."
}
]
```
### **2. 根据step查询提示词**
获取指定step阶段的所有提示词。
* **Endpoint:** `GET /prompts/step/{step}`
* **路径参数 (Path):** `step` (String) - 提示词使用的阶段
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Array<Object>`
<!-- end list -->
```json
[
{
"id": 1,
"step": "create_shots",
"name": "default_v1",
"prompt": "这是一个用于生成分镜的提示词...",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T11:00:00Z"
},
{
"id": 2,
"step": "create_shots",
"name": "custom_v2",
"prompt": "这是另一个用于生成分镜的提示词...",
"created_at": "2025-08-27T10:35:00Z",
"updated_at": "2025-08-27T11:05:00Z"
}
]
```
* **错误响应:**
* **500 Internal Server Error:** 查询失败
```json
{
"detail": "根据step获取提示词失败: 具体错误信息"
}
```
### **3. 新增或修改提示词**
创建一个新的提示词,或者根据 `step``name` 更新一个已有的提示词。
* **Endpoint:** `POST /prompts`
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `step` | String | 是 | 提示词使用的阶段 |
| `name` | String | 是 | 提示词的名称 |
| `prompt` | String | 是 | 提示词的具体内容 |
**示例:**
```json
{
"step": "create_shots",
"name": "default_v1",
"prompt": "请根据以下剧本和素材描述,生成分镜列表..."
}
```
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"id": 1,
"step": "create_shots",
"name": "default_v1",
"prompt": "请根据以下剧本和素材描述,生成分镜列表...",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T11:00:00Z"
}
```
### **4. 删除提示词**
删除指定ID的提示词。
* **Endpoint:** `DELETE /prompts/{prompt_id}`
* **路径参数 (Path):** `prompt_id` (Integer) - 要删除的提示词ID
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"message": "提示词删除成功"
}
```
* **错误响应:**
* **404 Not Found:** 提示词不存在
```json
{
"detail": "提示词不存在"
}
```
* **500 Internal Server Error:** 删除失败
```json
{
"detail": "删除提示词失败: 具体错误信息"
}
```
-----
## **模块二:项目与视频生成 (Project & Video Generation)**
### **1. 分页获取项目列表**
获取已创建项目的分页列表。
* **Endpoint:** `GET /projects`
* **查询参数 (Query):**
| 参数名 | 类型 | 必填 | 默认值 | 描述 |
| :--- | :--- | :--- | :--- | :--- |
| `page` | Integer | 否 | 1 | 页码 |
| `size` | Integer | 否 | 20 | 每页数量 |
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"items": [
{
"id": 1,
"name": "我的第一个项目",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T11:00:00Z"
}
],
"total": 1,
"page": 1,
"size": 20
}
```
### **2. 新建项目**
创建一个新的空项目。
* **Endpoint:** `POST /projects`
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `name` | String | 是 | 项目名称 |
**示例:**
```json
{
"name": "我的第一个项目"
}
```
* **成功响应 (201 Created):**
* **Type:** `Object`
<!-- end list -->
```json
{
"id": 1,
"name": "我的第一个项目"
}
```
### **3. 删除项目**
软删除指定的项目(不会真正删除数据,只是标记为已删除)。
* **Endpoint:** `DELETE /projects/{project_id}`
* **路径参数 (Path):** `project_id` (Integer)
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"message": "项目 1 删除成功"
}
```
* **错误响应:**
* **404 Not Found:** 项目不存在
```json
{
"detail": "项目 1 不存在"
}
```
* **500 Internal Server Error:** 删除失败
```json
{
"detail": "删除项目失败: 具体错误信息"
}
```
### **4. 生成剧本**
根据创意或剧本生成完整剧本和所需素材信息。这是新的工作流程的第一步。
> **重要更新:** 现在支持自定义提示词模板!前端可以让用户选择并微调提示词模板,然后传递给后端。
* **Endpoint:** `POST /projects/{project_id}/generate-script`
* **路径参数 (Path):** `project_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `script_or_idea` | String | 是 | 剧本或创意内容 |
| `prompt_template` | String | 是 | 提示词模板,必须包含 `${script_or_idea}` 占位符 |
**示例:**
```json
{
"script_or_idea": "一个关于小猫冒险的故事,小猫在森林里遇到了各种动物朋友",
"prompt_template": "基于以下创意或剧本,生成一个完整的视频剧本和所需的素材列表。\n\n创意/剧本:${script_or_idea}\n\n请返回以下JSON格式的结果\n{\n \"full_script\": \"完整的视频剧本内容,包含详细的场景描述、对话、动作等\",\n \"assets\": [\n {\n \"name\": \"素材名称\",\n \"description\": \"素材的详细描述\",\n \"tags\": [\"标签1\", \"标签2\", \"标签3\"]\n }\n ]\n}\n\n要求\n1. 完整剧本应该包含影视风格和详细的整个完整故事(大概30~90秒)\n2. 素材列表应该包含制作这个视频所需的所有关键元素(人物、场景、道具、动物等视觉相关的元素)\n3. 每个素材都要有清晰的名称、描述和相关标签\n4. 确保所有素材的风格统一\n5. 确保返回的是有效的JSON格式不要包含其他文字"
}
```
* **模板变量说明:**
- `${script_or_idea}`: 会被替换为用户输入的剧本或创意内容
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"full_script": "第一场:森林入口\n小猫咪咪好奇地走进了神秘的森林...",
"assets": [
{
"id": 1,
"name": "小猫咪咪",
"description": "一只橘色的小猫,有着明亮的绿眼睛",
"tags": ["动物", "主角", "小猫"],
"original_url": null,
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T10:30:00Z"
},
{
"id": 2,
"name": "神秘森林",
"description": "一片茂密的森林,阳光透过树叶洒下",
"tags": ["场景", "森林", "自然"],
"original_url": null,
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T10:30:00Z"
}
]
}
```
* **错误响应:**
* **400 Bad Request:** 提示词模板缺少必需变量
```json
{
"detail": "提示词模板缺少必需的变量: script_or_idea。请确保模板中包含: ${script_or_idea}"
}
```
### **5. 更新素材图**
为素材更新图片URL。用户可以为生成的素材提供自定义的图片。
* **Endpoint:** `PUT /assets/{asset_id}/image`
* **路径参数 (Path):** `asset_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `image_url` | String | 是 | 素材图片URL |
**示例:**
```json
{
"image_url": "https://example.com/my-custom-image.jpg"
}
```
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"message": "素材图片更新成功"
}
```
### **6. 生成单个素材图**
为指定的单个素材生成图片。前端提供素材ID后端返回生成图片后的素材信息。
* **Endpoint:** `POST /assets/{asset_id}/generate-image`
* **路径参数 (Path):** `asset_id` (Integer) - 要生成图片的素材ID
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Object`
* **描述:** 返回生成图片后的素材信息
<!-- end list -->
```json
{
"asset": {
"id": 1,
"project_id": 1,
"name": "小猫咪咪",
"description": "一只橘色的小猫,有着明亮的绿眼睛",
"tags": ["动物", "主角", "小猫"],
"original_url": "https://your-qiniu-domain.com/generated_cat_123.png",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T10:35:00Z"
}
}
```
* **错误响应:**
* **404 Not Found:** 素材不存在
```json
{
"detail": "素材 1 不存在"
}
```
* **500 Internal Server Error:** 生成失败
```json
{
"detail": "生成素材图失败: 具体错误信息"
}
```
### **7. 批量创建素材图**
为项目中所有没有图片的素材自动生成图片。如果所有素材都已有图片,则可以跳过此步骤。
* **Endpoint:** `POST /projects/{project_id}/generate-asset-images`
* **路径参数 (Path):** `project_id` (Integer)
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Array<Object>`
* **描述:** 返回项目的所有素材信息(包括新生成图片的素材)
<!-- end list -->
```json
[
{
"id": 1,
"name": "小猫咪咪",
"description": "一只橘色的小猫,有着明亮的绿眼睛",
"tags": ["动物", "主角", "小猫"],
"original_url": "https://your-qiniu-domain.com/generated_cat_123.png",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T10:35:00Z"
},
{
"id": 2,
"name": "神秘森林",
"description": "一片茂密的森林,阳光透过树叶洒下",
"tags": ["场景", "森林", "自然"],
"original_url": "https://your-qiniu-domain.com/generated_forest_456.png",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T10:35:00Z"
}
]
```
### **8. 关联素材** [已弃用 - 保留兼容性]
> **注意:** 此接口已被新的工作流程替代,但保留用于兼容性。新的推荐流程是:生成剧本 → 更新/生成素材图 → 生成分镜。
* **Endpoint:** `POST /projects/{project_id}/assets`
* **路径参数 (Path):** `project_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `asset_urls` | Array\<String\> | 是 | **已上传到七牛云的**素材URL数组 |
**示例:**
```json
{
"asset_urls": [
"https://your-qiniu-domain.com/image1.jpg",
"https://your-qiniu-domain.com/image2.png"
]
}
```
* **成功响应 (200 OK):**
* **Type:** `Array<Object>`
* **描述:** 返回分析和入库后的素材信息列表。
<!-- end list -->
```json
[
{
"id": 1,
"name": "海滩上的女孩",
"description": "一个穿着白色连衣裙的女孩在夕阳下的海滩上奔跑。",
"tags": ["海滩", "女孩", "夕阳", "连衣裙"]
}
]
```
### **9. 删除素材**
删除指定ID的素材。
* **Endpoint:** `DELETE /assets/{asset_id}`
* **路径参数 (Path):** `asset_id` (Integer) - 要删除的素材ID
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"message": "素材 1 删除成功"
}
```
* **错误响应:**
* **404 Not Found:** 素材不存在
```json
{
"detail": "素材 1 不存在"
}
```
* **500 Internal Server Error:** 删除失败
```json
{
"detail": "删除素材失败: 具体错误信息"
}
```
### **10. 生成分镜**
为指定项目提交剧本和提示词,生成一系列结构化的分镜描述。
> **重要更新:** 现在支持自定义提示词模板!前端可以让用户选择并微调提示词模板,然后传递给后端。
* **Endpoint:** `POST /projects/{project_id}/generate-storyboards`
* **路径参数 (Path):** `project_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `script` | String | 是 | 用户输入的剧本内容 |
| `prompt_template` | String | 是 | 提示词模板,必须包含 `${materials_description}``${script}` 占位符 |
**示例:**
```json
{
"script": "清晨,女孩在海边醒来...",
"prompt_template": "请根据以下剧本和素材描述,生成分镜列表。\n\n可用素材描述\n${materials_description}\n\n用户剧本\n${script}\n\n请根据以上信息生成分镜返回JSON数组格式每个分镜包含\n- frame_prompt: 首帧图片生成提示词\n- frame_asset_ids: 用于生成首帧的素材ID数组\n- shot_prompt: 分镜动态内容提示词\n\n请确保返回的是有效的JSON数组格式不要包含其他文字。"
}
```
* **模板变量说明:**
- `${materials_description}`: 会被替换为项目中所有素材的描述信息
- `${script}`: 会被替换为用户输入的剧本内容
* **成功响应 (200 OK):**
* **Type:** `Array<Object>`
<!-- end list -->
```json
[
{
"id": 1,
"frame_prompt": "清晨金色的阳光下...",
"frame_asset_ids": [1],
"shot_prompt": "镜头特写女孩的面部..."
}
]
```
* **错误响应:**
* **400 Bad Request:** 提示词模板缺少必需变量
```json
{
"detail": "提示词模板缺少必需的变量: materials_description, script。请确保模板中包含: ${materials_description}, ${script}"
}
```
### **11. 生成分镜首帧图**
为一个已生成的分镜,生成其首帧静态图。
* **Endpoint:** `POST /storyboards/{storyboard_id}/generate-frame`
* **路径参数 (Path):** `storyboard_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `frame_prompt` | String | 是 | 用于生成首帧图的提示词(可编辑) |
| `frame_asset_ids` | Array\<Integer\> | 是 | 用于生成首帧图的素材ID数组可编辑 |
**示例:**
```json
{
"frame_prompt": "日出时分,一个女孩在沙滩上醒来...",
"frame_asset_ids": [1]
}
```
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"frame_image_url": "https://your-qiniu-domain.com/generated_image_123.png"
}
```
### **12. 首帧图指导修改**
使用指导图修改分镜的首帧图。用户可以在白板上画草图来指导首帧图的生成。
* **Endpoint:** `POST /storyboards/{storyboard_id}/guide-frame`
* **路径参数 (Path):** `storyboard_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `guide_image_url` | String | 是 | 指导图URL用户在白板上画的动作或位置指导草图 |
**示例:**
```json
{
"guide_image_url": "https://example.com/my-guide-sketch.jpg"
}
```
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"frame_image_url": "https://your-qiniu-domain.com/guided_frame_789.png"
}
```
### **13. 生成分镜视频**
为一个已生成首帧图的分镜,生成其动态视频。
* **Endpoint:** `POST /storyboards/{storyboard_id}/generate-video`
* **路径参数 (Path):** `storyboard_id` (Integer)
* **请求体 (Body):**
| 字段名 | 类型 | 必填 | 描述 |
| :--- | :--- | :--- | :--- |
| `shot_prompt` | String | 是 | 用于生成分镜视频的动态描述提示词(可编辑) |
**示例:**
```json
{
"shot_prompt": "镜头从女孩的特写平滑地拉远..."
}
```
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"shot_video_url": "https://your-qiniu-domain.com/generated_video_456.mp4"
}
```
### **14. 合成项目视频**
将项目中所有分镜的视频合并成一个完整的项目视频。
* **Endpoint:** `POST /projects/{project_id}/compose-video`
* **路径参数 (Path):** `project_id` (Integer)
* **请求参数:** 无
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"video_url": "https://your-qiniu-domain.com/project_final_video_789.mp4"
}
```
* **错误响应:**
* **400 Bad Request:** 项目不存在或分镜没有视频
```json
{
"detail": "项目 1 的分镜都没有生成视频"
}
```
* **500 Internal Server Error:** 合成失败
```json
{
"detail": "合成项目视频失败: 具体错误信息"
}
```
### **15. 获取项目所有信息**
获取一个项目的完整信息,用于重新打开项目时恢复前端状态。
* **Endpoint:** `GET /projects/{project_id}`
* **路径参数 (Path):** `project_id` (Integer)
* **成功响应 (200 OK):**
* **Type:** `Object`
<!-- end list -->
```json
{
"project": {
"id": 1,
"name": "我的第一个项目",
"script": "清晨,女孩在海边醒来...",
"video_url": "https://your-qiniu-domain.com/project_final_video_789.mp4",
"created_at": "2025-08-27T10:30:00Z",
"updated_at": "2025-08-27T12:00:00Z"
},
"assets": [
{
"id": 1,
"name": "海滩上的女孩",
"description": "一个穿着白色连衣裙的女孩...",
"tags": ["海滩", "女孩"],
"original_url": "https://your-qiniu-domain.com/image1.jpg"
}
],
"storyboards": [
{
"id": 1,
"frame_prompt": "日出时分...",
"frame_asset_ids": [1],
"frame_image_url": "https://your-qiniu-domain.com/generated_image_123.png",
"shot_prompt": "镜头从女孩的特写平滑地拉远...",
"shot_video_url": "https://your-qiniu-domain.com/generated_video_456.mp4"
}
]
}
```

16
requirements.txt Normal file
View File

@ -0,0 +1,16 @@
fastapi
uvicorn[standard]
sqlalchemy
psycopg2-binary
alembic
pydantic
pydantic-settings
python-dotenv
google-generativeai
qiniu
openai
loguru
httpx
python-multipart
pillow
pymysql

113
src/api/dependencies.py Normal file
View File

@ -0,0 +1,113 @@
from fastapi import Depends
from sqlalchemy.orm import Session
from ..infrastructure.database.session import get_db
from ..infrastructure.external.gemini_client import GeminiClient
from ..infrastructure.external.openrouter_client import OpenRouterClient
from ..infrastructure.external.qiniu_client import QiniuClient
from ..infrastructure.database.repository_impl.prompt_repository_impl import PromptRepositoryImpl
from ..infrastructure.database.repository_impl.project_repository_impl import ProjectRepositoryImpl
from ..domain.services.ai_service import AIService
from ..domain.services.storage_service import StorageService
from ..application.use_cases.prompt_use_cases import PromptUseCases
from ..application.use_cases.project_use_cases import ProjectUseCases
# 基础设施依赖
def get_gemini_client() -> GeminiClient:
return GeminiClient()
def get_openrouter_client() -> OpenRouterClient:
return OpenRouterClient()
def get_qiniu_client() -> QiniuClient:
return QiniuClient()
# 仓储依赖
def get_prompt_repository(db: Session = Depends(get_db)) -> PromptRepositoryImpl:
return PromptRepositoryImpl(db)
def get_project_repository(db: Session = Depends(get_db)) -> ProjectRepositoryImpl:
return ProjectRepositoryImpl(db)
# 服务依赖
def get_ai_service(
gemini_client: GeminiClient = Depends(get_gemini_client),
openrouter_client: OpenRouterClient = Depends(get_openrouter_client)
) -> AIService:
return AIServiceImpl(gemini_client, openrouter_client)
def get_storage_service(
qiniu_client: QiniuClient = Depends(get_qiniu_client)
) -> StorageService:
return StorageServiceImpl(qiniu_client)
# 应用用例依赖
def get_prompt_use_cases(
prompt_repository: PromptRepositoryImpl = Depends(get_prompt_repository)
) -> PromptUseCases:
return PromptUseCases(prompt_repository)
def get_project_use_cases(
project_repository: ProjectRepositoryImpl = Depends(get_project_repository),
prompt_repository: PromptRepositoryImpl = Depends(get_prompt_repository),
ai_service: AIService = Depends(get_ai_service),
storage_service: StorageService = Depends(get_storage_service)
) -> ProjectUseCases:
return ProjectUseCases(project_repository, prompt_repository, ai_service, storage_service)
# 服务实现类
class AIServiceImpl(AIService):
"""AI服务实现"""
def __init__(self, gemini_client: GeminiClient, openrouter_client: OpenRouterClient):
self.gemini_client = gemini_client
self.openrouter_client = openrouter_client
async def analyze_image(self, image_bytes: bytes):
return self.gemini_client.analyze_image(image_bytes)
async def generate_script_and_assets(self, prompt_template: str, script_or_idea: str):
return await self.openrouter_client.generate_script_and_assets(prompt_template, script_or_idea)
def generate_image(self, prompt: str, reference_images=None):
return self.gemini_client.generate_image_from_prompt(prompt, reference_images)
async def generate_storyboards(self, prompt_template: str, materials_description: str, script: str):
return await self.openrouter_client.generate_storyboards(prompt_template, materials_description, script)
async def translate_text(self, text: str, source_lang: str = "zh", target_lang: str = "en"):
return await self.openrouter_client.translate_text(text, source_lang, target_lang)
async def generate_video(self, frame_image_bytes: bytes, shot_prompt: str):
return await self.gemini_client.generate_video(frame_image_bytes, shot_prompt)
async def analyze_video(self, video_url: str):
return await self.gemini_client.analyze_video(video_url)
class StorageServiceImpl(StorageService):
"""存储服务实现"""
def __init__(self, qiniu_client: QiniuClient):
self.qiniu_client = qiniu_client
def get_upload_token(self):
return self.qiniu_client.get_upload_token()
def upload_file(self, file_bytes: bytes, suffix: str):
return self.qiniu_client.upload_file(file_bytes, suffix)
def get_upload_info(self):
return self.qiniu_client.get_upload_info()
def merge_videos(self, video_urls):
return self.qiniu_client.merge_videos(video_urls)

483
src/api/routes/projects.py Normal file
View File

@ -0,0 +1,483 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List
from ...application.use_cases.project_use_cases import ProjectUseCases
from ...infrastructure.services.template_service import TemplateService
from ...application.schemas.project import (
ProjectCreate, ProjectResponse, ProjectListResponse, ProjectDetailResponse,
AssetCreate, AssetResponse, StoryboardCreate, StoryboardResponse,
GenerateScriptRequest, GenerateScriptResponse,
GenerateStoryboardsRequest,
UpdateAssetImageRequest, UpdateAssetImageResponse,
GenerateAssetImageResponse,
GuideFrameRequest, GuideFrameResponse,
DeleteProjectResponse, ComposeVideoResponse,
VideoReplicateRequest
)
from ...domain.entities.project import Project
from ...domain.entities.asset import Asset
from ...domain.entities.storyboard import Storyboard
from ..dependencies import get_project_use_cases
from datetime import datetime
router = APIRouter(prefix="/api/v1", tags=["projects"])
@router.get("/projects/", response_model=ProjectListResponse)
async def get_projects(
page: int = Query(1, ge=1, description="页码"),
size: int = Query(20, ge=1, le=100, description="每页大小"),
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""分页获取项目列表"""
try:
projects, total = await project_use_cases.get_projects_paginated(page, size)
return ProjectListResponse(
items=[ProjectResponse(
id=project.id or 0,
name=project.name,
script=project.script,
full_script=project.full_script,
prompt=project.prompt,
video_url=project.video_url,
created_at=project.created_at or datetime.now(),
updated_at=project.updated_at or datetime.now(),
deleted_at=project.deleted_at
) for project in projects],
total=total,
page=page,
size=size
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取项目列表失败: {str(e)}")
@router.post("/projects/", response_model=ProjectResponse, status_code=201)
async def create_project(
project_data: ProjectCreate,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""新建项目"""
try:
project = await project_use_cases.create_project(project_data)
return ProjectResponse(
id=project.id or 0,
name=project.name,
script=project.script,
full_script=project.full_script,
prompt=project.prompt,
video_url=project.video_url,
created_at=project.created_at or datetime.now(),
updated_at=project.updated_at or datetime.now(),
deleted_at=project.deleted_at
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建项目失败: {str(e)}")
@router.delete("/projects/{project_id}", response_model=DeleteProjectResponse)
async def delete_project(
project_id: int,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""删除项目(软删除)"""
try:
result = await project_use_cases.delete_project(project_id)
if result:
return DeleteProjectResponse(message=f"项目 {project_id} 删除成功")
else:
raise HTTPException(status_code=500, detail="删除项目失败")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除项目失败: {str(e)}")
@router.post("/projects/{project_id}/generate-script", response_model=GenerateScriptResponse)
async def generate_script(
project_id: int,
request_data: GenerateScriptRequest,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""生成剧本"""
try:
# 验证提示词模板是否包含必需的变量
required_variables = ["script_or_idea"]
template_variables = TemplateService.extract_template_variables(request_data.prompt_template)
missing_variables = [var for var in required_variables if var not in template_variables]
if missing_variables:
raise HTTPException(
status_code=400,
detail=f"提示词模板缺少必需的变量: {', '.join(missing_variables)}。请确保模板中包含: {', '.join([f'${{{var}}}' for var in missing_variables])}"
)
result = await project_use_cases.generate_script(
project_id,
request_data.script_or_idea,
request_data.prompt_template
)
return GenerateScriptResponse(
full_script=result["full_script"],
assets=[AssetResponse(
id=asset.id or 0,
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url,
created_at=asset.created_at or datetime.now(),
updated_at=asset.updated_at or datetime.now()
) for asset in result["assets"]]
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成剧本失败: {str(e)}")
@router.put("/assets/{asset_id}/image", response_model=UpdateAssetImageResponse)
async def update_asset_image(
asset_id: int,
request_data: UpdateAssetImageRequest,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""更新素材图"""
try:
await project_use_cases.update_asset_image(asset_id, request_data.image_url)
return UpdateAssetImageResponse(message="素材图片更新成功")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"更新素材图失败: {str(e)}")
@router.post("/assets/{asset_id}/generate-image", response_model=GenerateAssetImageResponse)
async def generate_single_asset_image(
asset_id: int,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""生成单个素材图"""
try:
asset = await project_use_cases.generate_single_asset_image(asset_id)
if not asset:
raise HTTPException(status_code=500, detail="生成素材图失败")
return GenerateAssetImageResponse(
asset=AssetResponse(
id=asset.id or 0,
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url,
created_at=asset.created_at or datetime.now(),
updated_at=asset.updated_at or datetime.now()
)
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成素材图失败: {str(e)}")
@router.post("/projects/{project_id}/generate-asset-images", response_model=List[AssetResponse])
async def generate_asset_images(
project_id: int,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""创建素材图"""
try:
assets = await project_use_cases.generate_asset_images(project_id)
return [AssetResponse(
id=asset.id or 0,
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url,
created_at=asset.created_at or datetime.now(),
updated_at=asset.updated_at or datetime.now()
) for asset in assets]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建素材图失败: {str(e)}")
@router.post("/projects/{project_id}/compose-video", response_model=ComposeVideoResponse)
async def compose_project_video(
project_id: int,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""合成项目视频"""
try:
video_url = await project_use_cases.compose_project_video(project_id)
return ComposeVideoResponse(video_url=video_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"合成项目视频失败: {str(e)}")
@router.post("/projects/{project_id}/assets", response_model=List[AssetResponse])
async def upload_and_analyze_assets(
project_id: int,
asset_data: dict,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
asset_urls = asset_data.get("asset_urls", [])
"""上传并分析素材"""
try:
assets = await project_use_cases.upload_and_analyze_assets(project_id, asset_urls)
return [AssetResponse(
id=asset.id or 0,
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url,
created_at=asset.created_at or datetime.now(),
updated_at=asset.updated_at or datetime.now()
) for asset in assets]
except Exception as e:
raise HTTPException(status_code=500, detail=f"上传并分析素材失败: {str(e)}")
@router.delete("/assets/{asset_id}")
async def delete_asset(
asset_id: int,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""删除素材"""
try:
result = await project_use_cases.delete_asset(asset_id)
if result:
return {"message": f"素材 {asset_id} 删除成功"}
else:
raise HTTPException(status_code=404, detail="素材不存在或删除失败")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除素材失败: {str(e)}")
@router.post("/projects/{project_id}/generate-storyboards", response_model=List[StoryboardResponse])
async def generate_storyboards(
project_id: int,
request_data: GenerateStoryboardsRequest,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""生成分镜"""
try:
# 验证提示词模板是否包含必需的变量
required_variables = ["materials_description", "script"]
template_variables = TemplateService.extract_template_variables(request_data.prompt_template)
missing_variables = [var for var in required_variables if var not in template_variables]
if missing_variables:
raise HTTPException(
status_code=400,
detail=f"提示词模板缺少必需的变量: {', '.join(missing_variables)}。请确保模板中包含: {', '.join([f'${{{var}}}' for var in missing_variables])}"
)
storyboards = await project_use_cases.generate_storyboards(
project_id,
request_data.script,
request_data.prompt_template
)
return [StoryboardResponse(
id=storyboard.id or 0,
project_id=storyboard.project_id,
frame_prompt=storyboard.frame_prompt,
shot_prompt=storyboard.shot_prompt,
frame_asset_ids=storyboard.frame_asset_ids,
frame_image_url=storyboard.frame_image_url,
shot_video_url=storyboard.shot_video_url,
created_at=storyboard.created_at or datetime.now(),
updated_at=storyboard.updated_at or datetime.now()
) for storyboard in storyboards]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成分镜失败: {str(e)}")
@router.post("/storyboards/{storyboard_id}/generate-frame")
async def generate_frame_image(
storyboard_id: int,
frame_data: dict, # 接收JSON body
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
frame_prompt = frame_data.get("frame_prompt", "")
frame_asset_ids = frame_data.get("frame_asset_ids", [])
"""生成分镜首帧图"""
try:
image_url = await project_use_cases.generate_frame_image(
storyboard_id, frame_prompt, frame_asset_ids
)
if image_url:
return {"frame_image_url": image_url}
else:
raise HTTPException(status_code=500, detail="生成首帧图失败")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成首帧图失败: {str(e)}")
@router.post("/storyboards/{storyboard_id}/guide-frame", response_model=GuideFrameResponse)
async def guide_frame_image(
storyboard_id: int,
request_data: GuideFrameRequest,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""首帧图指导修改"""
try:
frame_image_url = await project_use_cases.guide_frame_image(
storyboard_id, request_data.guide_image_url
)
return GuideFrameResponse(frame_image_url=frame_image_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"首帧图指导修改失败: {str(e)}")
@router.post("/storyboards/{storyboard_id}/generate-video")
async def generate_video(
storyboard_id: int,
video_data: dict, # 接收JSON body
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
shot_prompt = video_data.get("shot_prompt", "")
"""生成分镜视频"""
try:
video_url = await project_use_cases.generate_video(storyboard_id, shot_prompt)
if video_url:
return {"shot_video_url": video_url}
else:
raise HTTPException(status_code=500, detail="生成视频失败")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成视频失败: {str(e)}")
@router.get("/projects/{project_id}", response_model=ProjectDetailResponse)
async def get_project_detail(
project_id: int,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""获取项目所有信息"""
try:
project_detail = await project_use_cases.get_project_detail(project_id)
if not project_detail:
raise HTTPException(status_code=404, detail="项目不存在")
project = project_detail["project"]
assets = project_detail["assets"]
storyboards = project_detail["storyboards"]
return ProjectDetailResponse(
project=ProjectResponse(
id=project.id or 0,
name=project.name,
script=project.script,
full_script=project.full_script,
prompt=project.prompt,
video_url=project.video_url,
created_at=project.created_at or datetime.now(),
updated_at=project.updated_at or datetime.now(),
deleted_at=project.deleted_at
),
assets=[AssetResponse(
id=asset.id or 0,
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url,
created_at=asset.created_at or datetime.now(),
updated_at=asset.updated_at or datetime.now()
) for asset in assets],
storyboards=[StoryboardResponse(
id=storyboard.id or 0,
project_id=storyboard.project_id,
frame_prompt=storyboard.frame_prompt,
shot_prompt=storyboard.shot_prompt,
frame_asset_ids=storyboard.frame_asset_ids,
frame_image_url=storyboard.frame_image_url,
shot_video_url=storyboard.shot_video_url,
created_at=storyboard.created_at or datetime.now(),
updated_at=storyboard.updated_at or datetime.now()
) for storyboard in storyboards]
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取项目详情失败: {str(e)}")
@router.post("/projects/replicate", response_model=ProjectDetailResponse)
async def replicate_from_video(
request: VideoReplicateRequest,
project_use_cases: ProjectUseCases = Depends(get_project_use_cases)
):
"""一键复刻从视频URL生成项目、素材和分镜"""
try:
# 调用业务逻辑
result = await project_use_cases.replicate_from_video(request.video_url)
project = result["project"]
assets = result["assets"]
storyboards = result["storyboards"]
# 使用现有的ProjectDetailResponse格式返回
return ProjectDetailResponse(
project=ProjectResponse(
id=project.id or 0,
name=project.name,
script=project.script,
full_script=project.full_script,
prompt=project.prompt,
video_url=project.video_url,
created_at=project.created_at or datetime.now(),
updated_at=project.updated_at or datetime.now(),
deleted_at=project.deleted_at
),
assets=[AssetResponse(
id=asset.id or 0,
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url,
created_at=asset.created_at or datetime.now(),
updated_at=asset.updated_at or datetime.now()
) for asset in assets],
storyboards=[StoryboardResponse(
id=storyboard.id or 0,
project_id=storyboard.project_id,
frame_prompt=storyboard.frame_prompt,
shot_prompt=storyboard.shot_prompt,
frame_asset_ids=storyboard.frame_asset_ids,
frame_image_url=storyboard.frame_image_url,
shot_video_url=storyboard.shot_video_url,
created_at=storyboard.created_at or datetime.now(),
updated_at=storyboard.updated_at or datetime.now()
) for storyboard in storyboards]
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"一键复刻失败: {str(e)}")

86
src/api/routes/prompts.py Normal file
View File

@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...application.use_cases.prompt_use_cases import PromptUseCases
from ...application.schemas.prompt import PromptCreate, PromptResponse
from ...domain.entities.prompt import Prompt
from ..dependencies import get_prompt_use_cases
from datetime import datetime
router = APIRouter(prefix="/api/v1/prompts", tags=["prompts"])
@router.get("/", response_model=List[PromptResponse])
async def get_all_prompts(
prompt_use_cases: PromptUseCases = Depends(get_prompt_use_cases)
):
"""获取所有提示词"""
try:
prompts = await prompt_use_cases.get_all_prompts()
return [PromptResponse(
id=prompt.id or 0,
step=prompt.step,
name=prompt.name,
prompt=prompt.prompt,
created_at=prompt.created_at or datetime.now(),
updated_at=prompt.updated_at or datetime.now()
) for prompt in prompts]
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取提示词失败: {str(e)}")
@router.get("/step/{step}", response_model=List[PromptResponse])
async def get_prompts_by_step(
step: str,
prompt_use_cases: PromptUseCases = Depends(get_prompt_use_cases)
):
"""根据step获取提示词"""
try:
prompts = await prompt_use_cases.get_prompts_by_step(step)
return [PromptResponse(
id=prompt.id or 0,
step=prompt.step,
name=prompt.name,
prompt=prompt.prompt,
created_at=prompt.created_at or datetime.now(),
updated_at=prompt.updated_at or datetime.now()
) for prompt in prompts]
except Exception as e:
raise HTTPException(status_code=500, detail=f"根据step获取提示词失败: {str(e)}")
@router.post("/", response_model=PromptResponse)
async def create_or_update_prompt(
prompt_data: PromptCreate,
prompt_use_cases: PromptUseCases = Depends(get_prompt_use_cases)
):
"""新增或修改提示词"""
try:
prompt = await prompt_use_cases.create_or_update_prompt(prompt_data)
return PromptResponse(
id=prompt.id or 0,
step=prompt.step,
name=prompt.name,
prompt=prompt.prompt,
created_at=prompt.created_at or datetime.now(),
updated_at=prompt.updated_at or datetime.now()
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"保存提示词失败: {str(e)}")
@router.delete("/{prompt_id}")
async def delete_prompt(
prompt_id: int,
prompt_use_cases: PromptUseCases = Depends(get_prompt_use_cases)
):
"""删除提示词"""
try:
success = await prompt_use_cases.delete_prompt(prompt_id)
if success:
return {"message": "提示词删除成功"}
else:
raise HTTPException(status_code=404, detail="提示词不存在")
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除提示词失败: {str(e)}")

28
src/api/routes/qiniu.py Normal file
View File

@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException
from ...domain.services.storage_service import StorageService
from ..dependencies import get_storage_service
router = APIRouter(prefix="/api/v1/qiniu", tags=["qiniu"])
@router.get("/upload-token")
async def get_upload_token(
storage_service: StorageService = Depends(get_storage_service)
):
"""获取七牛云上传Token"""
try:
upload_info = storage_service.get_upload_info()
if not upload_info.get("token"):
raise HTTPException(status_code=500, detail="获取上传token失败")
return {
"token": upload_info["token"],
"domain": upload_info["domain"],
"bucket": upload_info["bucket"]
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取上传token失败: {str(e)}")

View File

@ -0,0 +1,163 @@
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
class ProjectBase(BaseModel):
"""项目基础模式"""
name: str = Field(..., description="项目名称")
class ProjectCreate(ProjectBase):
"""创建项目模式"""
pass
class ProjectUpdate(ProjectBase):
"""更新项目模式"""
script: Optional[str] = Field(None, description="剧本内容")
class ProjectResponse(ProjectBase):
"""项目响应模式"""
id: int
script: Optional[str] = None
full_script: Optional[str] = None
prompt: Optional[str] = None
video_url: Optional[str] = None
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProjectListResponse(BaseModel):
"""项目列表响应模式"""
items: List[ProjectResponse]
total: int
page: int
size: int
class ProjectDetailResponse(BaseModel):
"""项目详情响应模式"""
project: ProjectResponse
assets: List['AssetResponse']
storyboards: List['StoryboardResponse']
# 素材相关模式
class AssetBase(BaseModel):
"""素材基础模式"""
name: str = Field(..., description="素材名称")
description: Optional[str] = Field(None, description="素材描述")
tags: Optional[List[str]] = Field(None, description="素材标签")
class AssetCreate(AssetBase):
"""创建素材模式"""
original_url: str = Field(..., description="素材原始URL")
class AssetResponse(AssetBase):
"""素材响应模式"""
id: int
project_id: int
original_url: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# 分镜相关模式
class StoryboardBase(BaseModel):
"""分镜基础模式"""
frame_prompt: str = Field(..., description="首帧提示词")
shot_prompt: str = Field(..., description="分镜动态内容提示词")
class StoryboardCreate(StoryboardBase):
"""创建分镜模式"""
frame_asset_ids: Optional[List[int]] = Field(None, description="首帧素材ID数组")
class StoryboardResponse(StoryboardBase):
"""分镜响应模式"""
id: int
project_id: int
frame_asset_ids: Optional[List[int]] = None
frame_image_url: Optional[str] = None
shot_video_url: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# 新增的请求模式
class GenerateScriptRequest(BaseModel):
"""生成剧本请求模式"""
script_or_idea: str = Field(..., description="剧本或创意内容")
prompt_template: str = Field(..., description="提示词模板,包含${script_or_idea}占位符")
class GenerateScriptResponse(BaseModel):
"""生成剧本响应模式"""
full_script: str = Field(..., description="生成的完整剧本")
assets: List[AssetResponse] = Field(..., description="生成的素材列表")
class GenerateStoryboardsRequest(BaseModel):
"""生成分镜请求模式"""
script: str = Field(..., description="用户剧本")
prompt_template: str = Field(..., description="提示词模板,包含${materials_description}、${script}占位符")
class UpdateAssetImageRequest(BaseModel):
"""更新素材图请求模式"""
image_url: str = Field(..., description="素材图片URL")
class UpdateAssetImageResponse(BaseModel):
"""更新素材图响应模式"""
message: str = Field(..., description="响应消息")
class GenerateAssetImageResponse(BaseModel):
"""生成单个素材图响应模式"""
asset: AssetResponse = Field(..., description="生成图片后的素材信息")
class GuideFrameRequest(BaseModel):
"""首帧图指导修改请求模式"""
guide_image_url: str = Field(..., description="指导图URL")
class GuideFrameResponse(BaseModel):
"""首帧图指导修改响应模式"""
frame_image_url: str = Field(..., description="修改后的首帧图URL")
class DeleteProjectResponse(BaseModel):
"""删除项目响应模式"""
message: str = Field(..., description="删除结果消息")
class ComposeVideoResponse(BaseModel):
"""合成项目视频响应模式"""
video_url: str = Field(..., description="合成后的项目视频URL")
# 一键复刻相关模式
class VideoReplicateRequest(BaseModel):
"""一键复刻请求模式"""
video_url: str = Field(..., description="要复刻的视频URL")
# 更新引用
ProjectDetailResponse.model_rebuild()

View File

@ -0,0 +1,30 @@
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class PromptBase(BaseModel):
"""提示词基础模式"""
step: str = Field(..., description="提示词使用的阶段")
name: str = Field(..., description="提示词名称")
prompt: str = Field(..., description="提示词内容")
class PromptCreate(PromptBase):
"""创建提示词模式"""
pass
class PromptUpdate(PromptBase):
"""更新提示词模式"""
pass
class PromptResponse(PromptBase):
"""提示词响应模式"""
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,721 @@
import httpx
from typing import List, Optional, Dict, Any
from ...domain.repositories.project_repository import ProjectRepository
from ...domain.repositories.prompt_repository import PromptRepository
from ...domain.services.ai_service import AIService
from ...domain.services.storage_service import StorageService
from ...domain.entities.project import Project
from ...domain.entities.asset import Asset
from ...domain.entities.storyboard import Storyboard
from ..schemas.project import ProjectCreate, AssetCreate, StoryboardCreate
from ...infrastructure.services.video_service import VideoService
from loguru import logger
class ProjectUseCases:
"""项目应用用例"""
def __init__(
self,
project_repository: ProjectRepository,
prompt_repository: PromptRepository,
ai_service: AIService,
storage_service: StorageService
):
self.project_repository = project_repository
self.prompt_repository = prompt_repository
self.ai_service = ai_service
self.storage_service = storage_service
self.video_service = VideoService()
async def get_projects_paginated(self, page: int, size: int) -> tuple[List[Project], int]:
"""分页获取项目列表"""
return await self.project_repository.get_all_paginated(page, size)
async def create_project(self, project_data: ProjectCreate) -> Project:
"""创建新项目"""
project = Project(
id=None,
name=project_data.name
)
return await self.project_repository.save(project)
async def delete_project(self, project_id: int) -> bool:
"""软删除项目"""
# 检查项目是否存在
project = await self.project_repository.get_by_id(project_id)
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 执行软删除
result = await self.project_repository.delete(project_id)
if result:
logger.info(f"成功删除项目: {project_id}")
else:
logger.warning(f"删除项目失败: {project_id}")
return result
async def compose_project_video(self, project_id: int) -> str:
"""合成项目视频"""
# 检查项目是否存在
project = await self.project_repository.get_by_id(project_id)
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 获取项目的所有分镜
storyboards = await self.project_repository.get_storyboards_by_project_id(project_id)
if not storyboards:
raise ValueError(f"项目 {project_id} 没有分镜")
# 检查所有分镜是否都有视频
storyboards_with_video = [sb for sb in storyboards if sb.shot_video_url]
if not storyboards_with_video:
raise ValueError(f"项目 {project_id} 的分镜都没有生成视频")
if len(storyboards_with_video) < len(storyboards):
missing_count = len(storyboards) - len(storyboards_with_video)
logger.warning(f"项目 {project_id}{missing_count} 个分镜没有视频,将跳过这些分镜")
# 按分镜ID排序确保视频顺序正确
storyboards_with_video.sort(key=lambda x: x.id or 0)
# 提取视频URL列表
video_urls = [sb.shot_video_url for sb in storyboards_with_video if sb.shot_video_url]
logger.info(f"开始合成项目 {project_id} 的视频,共 {len(video_urls)} 个分镜视频")
# 合并视频并上传
video_url = self.storage_service.merge_videos(video_urls)
if not video_url:
raise ValueError("视频合成失败")
# 更新项目的视频URL
project.update_video_url(video_url)
await self.project_repository.save(project)
logger.info(f"项目 {project_id} 视频合成成功: {video_url}")
return video_url
async def generate_script(
self,
project_id: int,
script_or_idea: str,
prompt_template: str
) -> Dict[str, Any]:
"""生成剧本"""
# 检查项目是否存在
project = await self.project_repository.get_by_id(project_id)
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 清除项目的旧资源(素材和分镜)
logger.info(f"清除项目 {project_id} 的旧资源")
clear_success = await self.project_repository.clear_project_resources(project_id)
if not clear_success:
logger.warning(f"清除项目 {project_id} 旧资源失败,继续生成新剧本")
# 使用AI生成完整剧本和素材信息
script_result = await self.ai_service.generate_script_and_assets(
prompt_template,
script_or_idea
)
if not script_result:
raise ValueError("生成剧本失败")
full_script = script_result.get("full_script", "")
assets_data = script_result.get("assets", [])
# 更新项目的完整剧本并清除旧的项目视频URL
project.update_full_script(full_script)
project.update_video_url(None) # 清除旧的项目视频
await self.project_repository.save(project)
# 保存素材信息不包含图片URL
assets = []
for asset_data in assets_data:
asset = Asset(
id=None,
project_id=project_id,
name=asset_data.get("name", ""),
description=asset_data.get("description", ""),
tags=asset_data.get("tags", []),
original_url="" # 初始为空,后续通过生成或用户上传填充
)
saved_asset = await self.project_repository.save_asset(asset)
assets.append(saved_asset)
logger.info(f"项目 {project_id} 成功生成剧本和 {len(assets)} 个素材")
return {
"full_script": full_script,
"assets": assets
}
async def update_asset_image(self, asset_id: int, image_url: str) -> None:
"""更新素材图"""
# 检查素材是否存在
assets = await self.project_repository.get_assets_by_ids([asset_id])
if not assets:
raise ValueError(f"素材 {asset_id} 不存在")
# 更新素材的图片URL
await self.project_repository.update_asset(asset_id, original_url=image_url)
logger.info(f"成功更新素材 {asset_id} 的图片: {image_url}")
async def generate_asset_images(self, project_id: int) -> List[Asset]:
"""创建素材图"""
# 检查项目是否存在
project = await self.project_repository.get_by_id(project_id)
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 获取所有没有图片的素材
all_assets = await self.project_repository.get_assets_by_project_id(project_id)
assets_without_image = [asset for asset in all_assets if not asset.original_url]
if not assets_without_image:
logger.info(f"项目 {project_id} 所有素材都已有图片")
return all_assets
# 为每个没有图片的素材生成图片
for asset in assets_without_image:
try:
# 构建生成图片的提示词
prompt = f"{asset.name}"
if asset.description:
prompt += f", {asset.description}"
if asset.tags:
prompt += f", 标签: {', '.join(asset.tags)}"
# 使用AI生成图片
image_bytes = self.ai_service.generate_image(prompt, [])
if not image_bytes:
logger.warning(f"为素材 {asset.id} 生成图片失败")
continue
# 上传到存储
image_url = self.storage_service.upload_file(image_bytes, "png")
if not image_url:
logger.warning(f"为素材 {asset.id} 上传图片失败")
continue
# 更新素材的图片URL
await self.project_repository.update_asset(asset.id, original_url=image_url)
asset.original_url = image_url # 更新本地对象
logger.info(f"成功为素材 {asset.id} 生成图片: {image_url}")
except Exception as e:
logger.error(f"为素材 {asset.id} 生成图片失败: {e}")
continue
# 返回所有素材
return await self.project_repository.get_assets_by_project_id(project_id)
async def generate_single_asset_image(self, asset_id: int) -> Optional[Asset]:
"""为单个素材生成图片"""
try:
# 获取素材信息
asset = await self.project_repository.get_asset_by_id(asset_id)
if not asset:
raise ValueError(f"素材 {asset_id} 不存在")
# 检查素材是否已有图片
if asset.original_url:
logger.info(f"素材 {asset_id} 已有图片,将重新生成")
# 构建生成图片的提示词
prompt = f"{asset.name}"
if asset.description:
prompt += f", {asset.description}"
if asset.tags:
prompt += f", 标签: {', '.join(asset.tags)}"
# 使用AI生成图片
image_bytes = self.ai_service.generate_image(prompt, [])
if not image_bytes:
logger.error(f"为素材 {asset.id} 生成图片失败")
return None
# 上传到存储
image_url = self.storage_service.upload_file(image_bytes, "png")
if not image_url:
logger.error(f"为素材 {asset.id} 上传图片失败")
return None
# 更新素材的图片URL
updated_asset = await self.project_repository.update_asset(asset.id, original_url=image_url)
if updated_asset:
logger.info(f"成功为素材 {asset.id} 生成图片: {image_url}")
return updated_asset
else:
logger.error(f"更新素材 {asset.id} 图片URL失败")
return None
except Exception as e:
logger.error(f"为素材 {asset_id} 生成图片失败: {e}")
return None
async def get_project_detail(self, project_id: int) -> Optional[Dict[str, Any]]:
"""获取项目详情,包括素材和分镜"""
project = await self.project_repository.get_by_id(project_id)
if not project:
return None
assets = await self.project_repository.get_assets_by_project_id(project_id)
storyboards = await self.project_repository.get_storyboards_by_project_id(project_id)
return {
"project": project,
"assets": assets,
"storyboards": storyboards
}
async def upload_and_analyze_assets(
self,
project_id: int,
asset_urls: List[str]
) -> List[Asset]:
"""上传并分析素材"""
# 检查项目是否存在
project = await self.project_repository.get_by_id(project_id)
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 检查URL列表是否为空
if not asset_urls:
raise ValueError("素材URL列表不能为空")
# 检查URL数量限制
if len(asset_urls) > 50: # 限制单次最多上传50个素材
raise ValueError("单次最多只能上传50个素材")
assets = []
success_count = 0
failed_count = 0
async with httpx.AsyncClient() as client:
for url in asset_urls:
try:
# 检查URL格式
if not url.startswith(('http://', 'https://')):
logger.warning(f"跳过无效URL: {url}")
failed_count += 1
continue
# 下载图片
response = await client.get(url, timeout=30.0)
if response.status_code == 200:
image_bytes = response.content
# 检查图片大小
if len(image_bytes) > 10 * 1024 * 1024: # 10MB限制
logger.warning(f"图片过大,跳过: {url}, 大小: {len(image_bytes)} bytes")
failed_count += 1
continue
# 使用AI分析图片
analysis = await self.ai_service.analyze_image(image_bytes)
if analysis:
# 创建素材实体
asset = Asset(
id=None,
project_id=project_id,
name=analysis.get("name", "未命名素材"),
description=analysis.get("description"),
tags=analysis.get("tags", []),
original_url=url
)
# 保存到数据库
saved_asset = await self.project_repository.save_asset(asset)
assets.append(saved_asset)
success_count += 1
logger.info(f"成功处理素材: {url}")
else:
logger.warning(f"AI分析图片失败: {url}")
failed_count += 1
else:
logger.warning(f"下载图片失败: {url}, 状态码: {response.status_code}")
failed_count += 1
except Exception as e:
logger.error(f"处理素材失败: {url}, 错误: {e}")
failed_count += 1
continue
logger.info(f"素材处理完成: 成功 {success_count} 个, 失败 {failed_count}")
return assets
async def delete_asset(self, asset_id: int) -> bool:
"""删除素材"""
# 检查素材是否存在
assets = await self.project_repository.get_assets_by_ids([asset_id])
if not assets:
raise ValueError(f"素材 {asset_id} 不存在")
# 删除素材
result = await self.project_repository.delete_asset(asset_id)
if result:
logger.info(f"成功删除素材: {asset_id}")
else:
logger.warning(f"删除素材失败: {asset_id}")
return result
async def generate_storyboards(
self,
project_id: int,
script: str,
prompt_template: str
) -> List[Storyboard]:
"""生成分镜"""
# 检查所有素材是否都有图片
assets = await self.project_repository.get_assets_by_project_id(project_id)
assets_without_image = [asset for asset in assets if not asset.original_url]
if assets_without_image:
asset_names = [asset.name for asset in assets_without_image]
raise ValueError(f"以下素材缺少图片,请先生成或上传图片: {', '.join(asset_names)}")
# 先删除项目已存在的分镜
deleted_count = await self.project_repository.delete_storyboards_by_project_id(project_id)
if deleted_count > 0:
logger.info(f"删除项目 {project_id}{deleted_count} 个旧分镜")
# 构建素材描述
materials_description = ""
for asset in assets:
materials_description += f"素材ID: {asset.id}, 名称: {asset.name}, 描述: {asset.description or ''}, 标签: {', '.join(asset.tags or [])}\n"
# 使用AI生成分镜
storyboard_data = await self.ai_service.generate_storyboards(
prompt_template,
materials_description,
script
)
if not storyboard_data:
raise ValueError("AI生成分镜失败")
# 更新项目的提示词和剧本
project = await self.project_repository.get_by_id(project_id)
if project:
project.update_prompt(prompt_template)
project.update_script(script)
await self.project_repository.save(project)
# 保存分镜到数据库
storyboards = []
for data in storyboard_data:
storyboard = Storyboard(
id=None,
project_id=project_id,
frame_prompt=data.get("frame_prompt", ""),
shot_prompt=data.get("shot_prompt", ""),
frame_asset_ids=data.get("frame_asset_ids", [])
)
saved_storyboard = await self.project_repository.save_storyboard(storyboard)
storyboards.append(saved_storyboard)
logger.info(f"项目 {project_id} 成功生成 {len(storyboards)} 个分镜")
return storyboards
async def generate_frame_image(
self,
storyboard_id: int,
frame_prompt: str,
frame_asset_ids: List[int]
) -> Optional[str]:
"""生成分镜首帧图"""
# 检查素材是否存在
if frame_asset_ids:
assets = await self.project_repository.get_assets_by_ids(frame_asset_ids)
if len(assets) != len(frame_asset_ids):
raise ValueError(f"部分素材不存在请求的素材ID: {frame_asset_ids}")
else:
logger.warning(f"分镜 {storyboard_id} 没有指定素材ID将生成无参考图片")
# 获取素材图片
assets = await self.project_repository.get_assets_by_ids(frame_asset_ids)
reference_images = []
async with httpx.AsyncClient() as client:
for asset in assets:
try:
response = await client.get(asset.original_url, timeout=30.0)
if response.status_code == 200:
reference_images.append(response.content)
except Exception as e:
logger.error(f"下载素材图片失败: {asset.original_url}, 错误: {e}")
# 使用AI生成图片
image_bytes = self.ai_service.generate_image(frame_prompt, reference_images)
if not image_bytes:
raise ValueError("AI生成图片失败")
# 上传到存储
image_url = self.storage_service.upload_file(image_bytes, "png")
if not image_url:
raise ValueError("上传图片失败")
# 更新分镜
await self.project_repository.update_storyboard(
storyboard_id,
frame_prompt=frame_prompt,
frame_asset_ids=frame_asset_ids,
frame_image_url=image_url
)
logger.info(f"分镜 {storyboard_id} 成功生成首帧图: {image_url}")
return image_url
async def guide_frame_image(
self,
storyboard_id: int,
guide_image_url: str
) -> str:
"""首帧图指导修改"""
# 获取分镜
storyboard = await self.project_repository.get_storyboard_by_id(storyboard_id)
if not storyboard:
raise ValueError(f"未找到分镜: {storyboard_id}")
# 检查是否有原首帧图
if not storyboard.frame_image_url:
raise ValueError("分镜缺少原首帧图片,请先生成首帧图")
# 下载原首帧图和指导图
async with httpx.AsyncClient() as client:
try:
# 下载原首帧图
original_response = await client.get(storyboard.frame_image_url, timeout=30.0)
if original_response.status_code != 200:
raise ValueError("下载原首帧图失败")
original_image_bytes = original_response.content
# 下载指导图
guide_response = await client.get(guide_image_url, timeout=30.0)
if guide_response.status_code != 200:
raise ValueError("下载指导图失败")
guide_image_bytes = guide_response.content
except Exception as e:
raise ValueError(f"下载图片失败: {e}")
# 使用AI结合原图和指导图生成新的首帧图
prompt = f"Adjust the character's movements and position, maintain the style and main elements of the first image, optimize the posture and composition according to the instructions of the second sketch, enhance the dynamism and harmony of the image, keep the style consistent with the original image, and maintain the original aspect ratio."
new_image_bytes = self.ai_service.generate_image(
prompt,
[original_image_bytes, guide_image_bytes]
)
if not new_image_bytes:
raise ValueError("AI生成指导后的首帧图失败")
# 上传新图片到存储
new_image_url = self.storage_service.upload_file(new_image_bytes, "png")
if not new_image_url:
raise ValueError("上传新首帧图失败")
# 更新分镜的首帧图URL覆盖原图
await self.project_repository.update_storyboard(
storyboard_id,
frame_image_url=new_image_url
)
logger.info(f"分镜 {storyboard_id} 成功完成首帧图指导修改: {new_image_url}")
return new_image_url
async def generate_video(
self,
storyboard_id: int,
shot_prompt: str
) -> Optional[str]:
"""生成分镜视频"""
# 获取分镜
storyboard = await self.project_repository.get_storyboard_by_id(storyboard_id)
if not storyboard:
raise ValueError(f"未找到分镜: {storyboard_id}")
# 检查是否有首帧图
if not storyboard.frame_image_url:
raise ValueError("分镜缺少首帧图片,请先生成首帧图")
# 下载首帧图片
async with httpx.AsyncClient() as client:
try:
response = await client.get(storyboard.frame_image_url, timeout=30.0)
if response.status_code != 200:
raise ValueError("下载首帧图片失败")
image_bytes = response.content
# 翻译提示词为英文
translated_prompt = await self.ai_service.translate_text(shot_prompt, "zh", "en")
if not translated_prompt:
logger.warning(f"翻译提示词失败,使用原始提示词: {shot_prompt}")
translated_prompt = shot_prompt
else:
logger.info(f"提示词翻译成功: {shot_prompt} -> {translated_prompt}")
# 使用AI生成视频
video_bytes = await self.ai_service.generate_video(
image_bytes,
translated_prompt
)
if not video_bytes:
raise ValueError("AI生成视频失败")
# 上传视频到存储
video_url = self.storage_service.upload_file(video_bytes, "mp4")
if not video_url:
raise ValueError("上传视频失败")
# 更新分镜
await self.project_repository.update_storyboard(
storyboard_id,
shot_prompt=shot_prompt,
shot_video_url=video_url
)
logger.info(f"分镜 {storyboard_id} 成功生成视频: {video_url}")
return video_url
except Exception as e:
logger.error(f"生成视频失败: {e}")
raise ValueError(f"生成视频失败: {e}")
async def replicate_from_video(self, video_url: str) -> Dict[str, Any]:
"""
一键复刻从视频URL生成项目素材和分镜
Args:
video_url: 要复刻的视频URL
Returns:
包含projectassetsstoryboards的字典
"""
try:
logger.info(f"开始一键复刻视频: {video_url}")
# 1. 使用Gemini分析视频内容
logger.info("正在分析视频内容...")
analysis_result = await self.ai_service.analyze_video(video_url)
if not analysis_result:
raise ValueError("视频分析失败")
title = analysis_result.get("title", "")
script = analysis_result.get("script", "")
key_assets_frames = analysis_result.get("key_assets_frames", [])
key_storyboard_frames = analysis_result.get("key_storyboard_frames", [])
if not key_assets_frames or not key_storyboard_frames:
raise ValueError("视频分析结果为空")
logger.info(f"视频分析完成,生成标题: {title},剧本长度: {len(script)} 字符,提取了 {len(key_assets_frames)} 个素材帧和 {len(key_storyboard_frames)} 个分镜帧")
# 2. 创建项目并保存剧本
project_name = title if title else f"复刻项目_{int(__import__('time').time())}"
project = Project(
id=None,
name=project_name,
script=script # 保存生成的剧本
)
saved_project = await self.project_repository.save(project)
project_id = saved_project.id
logger.info(f"创建项目成功ID: {project_id},剧本已保存")
# 3. 提取并处理素材帧
logger.info("正在提取素材帧...")
assets = []
# 提取素材帧的时间戳
asset_timestamps = [frame["timestamp"] for frame in key_assets_frames]
frame_results = await self.video_service.extract_multiple_frames(video_url, asset_timestamps)
for i, (frame_data, (timestamp, frame_bytes)) in enumerate(zip(key_assets_frames, frame_results)):
try:
if frame_bytes:
# 上传素材帧到七牛云
asset_url = self.storage_service.upload_file(frame_bytes, "jpg")
if asset_url:
# 创建素材
asset = Asset(
id=None,
project_id=project_id,
name=frame_data.get("name", f"素材_{i+1}"),
description=frame_data.get("description", ""),
tags=frame_data.get("tags", []),
original_url=asset_url
)
saved_asset = await self.project_repository.save_asset(asset)
assets.append(saved_asset)
logger.info(f"素材 {saved_asset.id} 创建成功: {saved_asset.name}")
else:
logger.warning(f"上传素材帧失败: {timestamp}")
else:
logger.warning(f"提取素材帧失败: {timestamp}")
except Exception as e:
logger.error(f"处理素材帧 {timestamp} 失败: {e}")
continue
if not assets:
raise ValueError("没有成功创建任何素材")
# 4. 提取并处理分镜帧
logger.info("正在提取分镜帧...")
storyboards = []
# 提取分镜帧的时间戳
storyboard_timestamps = [frame["timestamp"] for frame in key_storyboard_frames]
storyboard_frame_results = await self.video_service.extract_multiple_frames(video_url, storyboard_timestamps)
for i, (frame_data, (timestamp, frame_bytes)) in enumerate(zip(key_storyboard_frames, storyboard_frame_results)):
try:
frame_image_url = None
if frame_bytes:
# 上传分镜首帧到七牛云
frame_image_url = self.storage_service.upload_file(frame_bytes, "jpg")
if frame_image_url:
logger.info(f"分镜首帧 {i+1} 上传成功")
else:
logger.warning(f"上传分镜首帧失败: {timestamp}")
else:
logger.warning(f"提取分镜首帧失败: {timestamp}")
# 创建分镜(即使首帧图上传失败也创建分镜)
storyboard = Storyboard(
id=None,
project_id=project_id,
frame_prompt=frame_data.get("frame_prompt", f"{i+1}个分镜画面"),
shot_prompt=frame_data.get("shot_prompt", f"{i+1}个分镜剧情"),
frame_asset_ids=[], # 初始为空,用户可以后续手动关联
frame_image_url=frame_image_url
)
saved_storyboard = await self.project_repository.save_storyboard(storyboard)
storyboards.append(saved_storyboard)
logger.info(f"分镜 {saved_storyboard.id} 创建成功")
except Exception as e:
logger.error(f"处理分镜帧 {timestamp} 失败: {e}")
continue
if not storyboards:
raise ValueError("没有成功创建任何分镜")
logger.info(f"一键复刻完成项目ID: {project_id},素材数: {len(assets)}, 分镜数: {len(storyboards)}")
return {
"project": saved_project,
"assets": assets,
"storyboards": storyboards
}
except Exception as e:
logger.error(f"一键复刻失败: {e}")
raise ValueError(f"一键复刻失败: {e}")

View File

@ -0,0 +1,48 @@
from typing import List, Optional
from ...domain.repositories.prompt_repository import PromptRepository
from ...domain.entities.prompt import Prompt
from ..schemas.prompt import PromptCreate, PromptUpdate
class PromptUseCases:
"""提示词应用用例"""
def __init__(self, prompt_repository: PromptRepository):
self.prompt_repository = prompt_repository
async def get_all_prompts(self) -> List[Prompt]:
"""获取所有提示词"""
return await self.prompt_repository.get_all()
async def create_or_update_prompt(self, prompt_data: PromptCreate) -> Prompt:
"""创建或更新提示词"""
# 检查是否已存在
existing_prompt = await self.prompt_repository.get_by_step_and_name(
prompt_data.step, prompt_data.name
)
if existing_prompt:
# 更新现有提示词
existing_prompt.prompt = prompt_data.prompt
return await self.prompt_repository.save(existing_prompt)
else:
# 创建新提示词
new_prompt = Prompt(
id=None,
step=prompt_data.step,
name=prompt_data.name,
prompt=prompt_data.prompt
)
return await self.prompt_repository.save(new_prompt)
async def get_prompt_by_step_and_name(self, step: str, name: str) -> Optional[Prompt]:
"""根据step和name获取提示词"""
return await self.prompt_repository.get_by_step_and_name(step, name)
async def get_prompts_by_step(self, step: str) -> List[Prompt]:
"""根据step获取所有提示词"""
return await self.prompt_repository.get_by_step(step)
async def delete_prompt(self, prompt_id: int) -> bool:
"""删除提示词"""
return await self.prompt_repository.delete(prompt_id)

View File

@ -0,0 +1,32 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List
@dataclass
class Asset:
"""素材领域实体"""
id: Optional[int]
project_id: int
name: str
original_url: Optional[str] = None
description: Optional[str] = None
tags: Optional[List[str]] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def __post_init__(self):
"""验证实体完整性"""
if not self.project_id:
raise ValueError("project_id不能为空")
if not self.name:
raise ValueError("name不能为空")
# original_url可以为空在生成素材时会填充
def update_description(self, description: str):
"""更新素材描述"""
self.description = description
def update_tags(self, tags: List[str]):
"""更新素材标签"""
self.tags = tags

View File

@ -0,0 +1,46 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List, Dict, Any
@dataclass
class Project:
"""项目领域实体"""
id: Optional[int]
name: str
script: Optional[str] = None
full_script: Optional[str] = None
prompt: Optional[str] = None
video_url: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
def __post_init__(self):
"""验证实体完整性"""
if not self.name:
raise ValueError("name不能为空")
def update_script(self, script: str):
"""更新剧本内容"""
self.script = script
def update_full_script(self, full_script: str):
"""更新完整剧本内容"""
self.full_script = full_script
def update_prompt(self, prompt: str):
"""更新提示词"""
self.prompt = prompt
def update_video_url(self, video_url: Optional[str]):
"""更新项目视频URL"""
self.video_url = video_url
def soft_delete(self):
"""软删除项目"""
self.deleted_at = datetime.now()
def is_deleted(self) -> bool:
"""检查项目是否已被删除"""
return self.deleted_at is not None

View File

@ -0,0 +1,23 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Prompt:
"""提示词领域实体"""
id: Optional[int]
step: str
name: str
prompt: str
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def __post_init__(self):
"""验证实体完整性"""
if not self.step:
raise ValueError("step不能为空")
if not self.name:
raise ValueError("name不能为空")
if not self.prompt:
raise ValueError("prompt不能为空")

View File

@ -0,0 +1,46 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, List
@dataclass
class Storyboard:
"""分镜领域实体"""
id: Optional[int]
project_id: int
frame_prompt: str
shot_prompt: str
frame_asset_ids: Optional[List[int]] = None
frame_image_url: Optional[str] = None
shot_video_url: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
def __post_init__(self):
"""验证实体完整性"""
if not self.project_id:
raise ValueError("project_id不能为空")
if not self.frame_prompt:
raise ValueError("frame_prompt不能为空")
if not self.shot_prompt:
raise ValueError("shot_prompt不能为空")
def update_frame_image_url(self, frame_image_url: str):
"""更新首帧图片URL"""
self.frame_image_url = frame_image_url
def update_shot_video_url(self, shot_video_url: str):
"""更新分镜视频URL"""
self.shot_video_url = shot_video_url
def update_frame_prompt(self, frame_prompt: str):
"""更新首帧提示词"""
self.frame_prompt = frame_prompt
def update_shot_prompt(self, shot_prompt: str):
"""更新分镜提示词"""
self.shot_prompt = shot_prompt
def update_frame_asset_ids(self, frame_asset_ids: List[int]):
"""更新首帧素材ID列表"""
self.frame_asset_ids = frame_asset_ids

View File

@ -0,0 +1,92 @@
from abc import ABC, abstractmethod
from typing import List, Optional, Tuple
from ..entities.project import Project
from ..entities.asset import Asset
from ..entities.storyboard import Storyboard
class ProjectRepository(ABC):
"""项目仓储接口,统一管理项目聚合根下的所有实体"""
# 项目相关方法
@abstractmethod
async def get_all_paginated(self, page: int, size: int) -> Tuple[List[Project], int]:
"""分页获取所有项目"""
pass
@abstractmethod
async def get_by_id(self, project_id: int) -> Optional[Project]:
"""根据ID获取项目"""
pass
@abstractmethod
async def save(self, project: Project) -> Project:
"""保存项目(新增或更新)"""
pass
@abstractmethod
async def delete(self, project_id: int) -> bool:
"""删除项目"""
pass
# 素材相关方法
@abstractmethod
async def get_assets_by_project_id(self, project_id: int) -> List[Asset]:
"""获取项目的所有素材"""
pass
@abstractmethod
async def save_asset(self, asset: Asset) -> Asset:
"""保存素材"""
pass
@abstractmethod
async def get_assets_by_ids(self, asset_ids: List[int]) -> List[Asset]:
"""根据ID列表获取素材"""
pass
@abstractmethod
async def get_asset_by_id(self, asset_id: int) -> Optional[Asset]:
"""根据ID获取单个素材"""
pass
@abstractmethod
async def delete_asset(self, asset_id: int) -> bool:
"""删除素材"""
pass
@abstractmethod
async def update_asset(self, asset_id: int, **kwargs) -> Optional[Asset]:
"""更新素材"""
pass
@abstractmethod
async def clear_project_resources(self, project_id: int) -> bool:
"""清除项目的所有资源(素材和分镜)"""
pass
# 分镜相关方法
@abstractmethod
async def get_storyboards_by_project_id(self, project_id: int) -> List[Storyboard]:
"""获取项目的所有分镜"""
pass
@abstractmethod
async def save_storyboard(self, storyboard: Storyboard) -> Storyboard:
"""保存分镜"""
pass
@abstractmethod
async def get_storyboard_by_id(self, storyboard_id: int) -> Optional[Storyboard]:
"""根据ID获取分镜"""
pass
@abstractmethod
async def update_storyboard(self, storyboard_id: int, **kwargs) -> Optional[Storyboard]:
"""更新分镜"""
pass
@abstractmethod
async def delete_storyboards_by_project_id(self, project_id: int) -> int:
"""删除项目的所有分镜,返回删除的数量"""
pass

View File

@ -0,0 +1,32 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from ..entities.prompt import Prompt
class PromptRepository(ABC):
"""提示词仓储接口"""
@abstractmethod
async def get_all(self) -> List[Prompt]:
"""获取所有提示词"""
pass
@abstractmethod
async def get_by_step_and_name(self, step: str, name: str) -> Optional[Prompt]:
"""根据step和name获取提示词"""
pass
@abstractmethod
async def get_by_step(self, step: str) -> List[Prompt]:
"""根据step获取所有提示词"""
pass
@abstractmethod
async def save(self, prompt: Prompt) -> Prompt:
"""保存提示词(新增或更新)"""
pass
@abstractmethod
async def delete(self, prompt_id: int) -> bool:
"""删除提示词"""
pass

View File

@ -0,0 +1,123 @@
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
class AIService(ABC):
"""AI服务接口"""
@abstractmethod
async def analyze_image(self, image_bytes: bytes) -> Optional[Dict[str, Any]]:
"""
分析图片内容
Args:
image_bytes: 图片二进制数据
Returns:
分析结果字典包含namedescriptiontags
"""
pass
@abstractmethod
async def generate_script_and_assets(
self,
prompt_template: str,
script_or_idea: str
) -> Optional[Dict[str, Any]]:
"""
生成完整剧本和素材信息
Args:
prompt_template: 提示词模板包含约定占位符${script_or_idea}
script_or_idea: 剧本或创意内容
Returns:
包含full_script和assets的字典
"""
pass
@abstractmethod
def generate_image(self, prompt: str, reference_images: Optional[List[bytes]] = None) -> Optional[bytes]:
"""
生成图片
Args:
prompt: 图片生成提示词
reference_images: 参考图片列表可选
Returns:
生成的图片二进制数据
"""
pass
@abstractmethod
async def generate_storyboards(
self,
prompt_template: str,
materials_description: str,
script: str
) -> Optional[List[Dict[str, Any]]]:
"""
生成分镜内容
Args:
prompt_template: 提示词模板包含约定占位符${materials_description}${script}
materials_description: 素材描述
script: 用户剧本
Returns:
分镜列表每个包含frame_prompt, frame_asset_ids, shot_prompt
"""
pass
@abstractmethod
async def translate_text(
self,
text: str,
source_lang: str = "zh",
target_lang: str = "en"
) -> Optional[str]:
"""
翻译文本
Args:
text: 要翻译的文本
source_lang: 源语言代码默认为中文(zh)
target_lang: 目标语言代码默认为英文(en)
Returns:
翻译后的文本失败返回None
"""
pass
@abstractmethod
async def generate_video(
self,
frame_image_bytes: bytes,
shot_prompt: str
) -> Optional[bytes]:
"""
生成视频
Args:
frame_image_bytes: 首帧图片二进制数据
shot_prompt: 视频生成提示词
duration_seconds: 视频时长
Returns:
生成的视频二进制数据
"""
pass
@abstractmethod
async def analyze_video(self, video_url: str) -> Optional[Dict[str, Any]]:
"""
分析视频内容提取关键素材帧和分镜关键帧
Args:
video_url: 视频URL
Returns:
分析结果字典包含key_assets_frames和key_storyboard_frames
"""
pass

View File

@ -0,0 +1,53 @@
from abc import ABC, abstractmethod
from typing import Optional, List
class StorageService(ABC):
"""存储服务接口"""
@abstractmethod
def get_upload_token(self) -> Optional[str]:
"""
获取上传token
Returns:
上传token失败返回None
"""
pass
@abstractmethod
def upload_file(self, file_bytes: bytes, suffix: str) -> Optional[str]:
"""
上传文件
Args:
file_bytes: 文件二进制数据
suffix: 文件扩展名
Returns:
文件URL失败返回None
"""
pass
@abstractmethod
def get_upload_info(self) -> dict:
"""
获取上传相关信息
Returns:
包含tokendomainbucket的字典
"""
pass
@abstractmethod
def merge_videos(self, video_urls: List[str]) -> Optional[str]:
"""
合并多个视频文件并上传到云存储
Args:
video_urls: 视频URL列表
Returns:
合并后的视频URL失败返回None
"""
pass

View File

@ -0,0 +1,36 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str
db_pool_size: int = 10
db_max_overflow: int = 20
db_pool_timeout: int = 30
db_pool_recycle: int = 3600
db_connect_timeout: int = 60
# Qiniu Cloud
qiniu_access_key: str
qiniu_secret_key: str
qiniu_bucket_name: str
qiniu_domain: str
# AI Models
google_api_key: str
openrouter_api_key: str
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# 代理设置(可选)
http_proxy: Optional[str] = None
https_proxy: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore" # 忽略额外的环境变量
# 全局配置实例
settings = Settings()

View File

@ -0,0 +1,84 @@
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship, Mapped, mapped_column
from .session import Base
from typing import Optional, List, Dict, Any
import datetime as dt
class BaseModel(Base):
"""基础模型类,包含通用字段"""
__abstract__ = True
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
created_at: Mapped[Optional[dt.datetime]] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[Optional[dt.datetime]] = mapped_column(
DateTime(timezone=True), onupdate=func.now()
)
deleted_at: Mapped[Optional[dt.datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
class Prompt(BaseModel):
"""提示词模型"""
__tablename__ = "prompts"
step: Mapped[str] = mapped_column(String(100), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), index=True, nullable=False)
prompt: Mapped[str] = mapped_column(Text, nullable=False)
__table_args__ = (
# 联合唯一约束 - 移除动态检查,使用标准约束
# 如果需要SQLite特定配置可以在迁移中处理
)
class Project(BaseModel):
"""项目模型"""
__tablename__ = "projects"
name: Mapped[str] = mapped_column(String(200), nullable=False)
script: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
full_script: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
prompt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
video_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# 关系
assets: Mapped[List["Asset"]] = relationship(
"Asset", back_populates="project", cascade="all, delete-orphan"
)
storyboards: Mapped[List["Storyboard"]] = relationship(
"Storyboard", back_populates="project", cascade="all, delete-orphan"
)
class Asset(BaseModel):
"""素材模型"""
__tablename__ = "assets"
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
tags: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)
original_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# 关系
project: Mapped["Project"] = relationship("Project", back_populates="assets")
class Storyboard(BaseModel):
"""分镜模型"""
__tablename__ = "storyboards"
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
frame_prompt: Mapped[str] = mapped_column(Text, nullable=False)
frame_asset_ids: Mapped[Optional[List[int]]] = mapped_column(JSON, nullable=True)
frame_image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
shot_prompt: Mapped[str] = mapped_column(Text, nullable=False)
shot_video_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# 关系
project: Mapped["Project"] = relationship("Project", back_populates="storyboards")

View File

@ -0,0 +1,252 @@
from typing import List, Optional, Tuple, cast
from loguru import logger
from sqlalchemy.orm import Session
from sqlalchemy import func
from ....domain.repositories.project_repository import ProjectRepository
from ....domain.entities.project import Project
from ....domain.entities.asset import Asset
from ....domain.entities.storyboard import Storyboard
from ..models import Project as ProjectModel, Asset as AssetModel, Storyboard as StoryboardModel
class ProjectRepositoryImpl(ProjectRepository):
"""项目仓储实现"""
def __init__(self, db: Session):
self.db = db
# 项目相关方法
async def get_all_paginated(self, page: int, size: int) -> Tuple[List[Project], int]:
"""分页获取所有项目"""
offset = (page - 1) * size
# 获取总数(过滤已删除的项目)
total = self.db.query(func.count(ProjectModel.id)).filter(ProjectModel.deleted_at.is_(None)).scalar()
# 获取分页数据(过滤已删除的项目)
db_projects = self.db.query(ProjectModel).filter(ProjectModel.deleted_at.is_(None)).offset(offset).limit(size).all()
projects = [self._to_project_entity(db_project) for db_project in db_projects]
return projects, total
async def get_by_id(self, project_id: int) -> Optional[Project]:
"""根据ID获取项目"""
db_project = self.db.query(ProjectModel).filter(
ProjectModel.id == project_id,
ProjectModel.deleted_at.is_(None)
).first() # type: ignore[arg-type]
if db_project:
return self._to_project_entity(db_project)
return None
async def save(self, project: Project) -> Project:
"""保存项目(新增或更新)"""
if project.id:
# 更新现有项目
db_project = self.db.query(ProjectModel).filter(
ProjectModel.id == project.id,
ProjectModel.deleted_at.is_(None)
).first() # type: ignore[arg-type]
if db_project:
db_project.name = project.name
db_project.script = project.script
db_project.full_script = project.full_script
db_project.prompt = project.prompt
db_project.video_url = project.video_url
db_project.deleted_at = project.deleted_at
else:
# 创建新项目
db_project = ProjectModel(
name=project.name,
script=project.script,
full_script=project.full_script,
prompt=project.prompt,
video_url=project.video_url,
deleted_at=project.deleted_at
)
self.db.add(db_project)
self.db.commit()
self.db.refresh(db_project)
return self._to_project_entity(db_project)
async def delete(self, project_id: int) -> bool:
"""软删除项目"""
db_project = self.db.query(ProjectModel).filter(
ProjectModel.id == project_id,
ProjectModel.deleted_at.is_(None)
).first() # type: ignore[arg-type]
if db_project:
db_project.deleted_at = func.now()
self.db.commit()
return True
return False
# 素材相关方法
async def get_assets_by_project_id(self, project_id: int) -> List[Asset]:
"""获取项目的所有素材"""
db_assets = self.db.query(AssetModel).filter(AssetModel.project_id == project_id).all() # type: ignore[arg-type]
return [self._to_asset_entity(db_asset) for db_asset in db_assets]
async def save_asset(self, asset: Asset) -> Asset:
"""保存素材"""
db_asset = AssetModel(
project_id=asset.project_id,
name=asset.name,
description=asset.description,
tags=asset.tags,
original_url=asset.original_url
)
self.db.add(db_asset)
self.db.commit()
self.db.refresh(db_asset)
return self._to_asset_entity(db_asset)
async def get_assets_by_ids(self, asset_ids: List[int]) -> List[Asset]:
"""根据ID列表获取素材"""
db_assets = self.db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all() # type: ignore[arg-type]
return [self._to_asset_entity(db_asset) for db_asset in db_assets]
async def get_asset_by_id(self, asset_id: int) -> Optional[Asset]:
"""根据ID获取单个素材"""
db_asset = self.db.query(AssetModel).filter(AssetModel.id == asset_id).first() # type: ignore[arg-type]
if db_asset:
return self._to_asset_entity(db_asset)
return None
async def delete_asset(self, asset_id: int) -> bool:
"""删除素材"""
db_asset = self.db.query(AssetModel).filter(AssetModel.id == asset_id).first() # type: ignore[arg-type]
if db_asset:
self.db.delete(db_asset)
self.db.commit()
return True
return False
async def update_asset(self, asset_id: int, **kwargs) -> Optional[Asset]:
"""更新素材"""
db_asset = self.db.query(AssetModel).filter(AssetModel.id == asset_id).first() # type: ignore[arg-type]
if db_asset:
for key, value in kwargs.items():
if hasattr(db_asset, key):
setattr(db_asset, key, value)
self.db.commit()
self.db.refresh(db_asset)
return self._to_asset_entity(db_asset)
return None
async def clear_project_resources(self, project_id: int) -> bool:
"""清除项目的所有资源(素材和分镜)"""
try:
# 删除项目的所有分镜
self.db.query(StoryboardModel).filter(
StoryboardModel.project_id == project_id
).delete(synchronize_session=False)
# 删除项目的所有素材
self.db.query(AssetModel).filter(
AssetModel.project_id == project_id
).delete(synchronize_session=False)
self.db.commit()
logger.info(f"成功清除项目 {project_id} 的所有资源")
return True
except Exception as e:
self.db.rollback()
logger.error(f"清除项目 {project_id} 资源失败: {e}")
return False
# 分镜相关方法
async def get_storyboards_by_project_id(self, project_id: int) -> List[Storyboard]:
"""获取项目的所有分镜"""
db_storyboards = self.db.query(StoryboardModel).filter(StoryboardModel.project_id == project_id).all() # type: ignore[arg-type]
return [self._to_storyboard_entity(db_storyboard) for db_storyboard in db_storyboards]
async def save_storyboard(self, storyboard: Storyboard) -> Storyboard:
"""保存分镜"""
db_storyboard = StoryboardModel(
project_id=storyboard.project_id,
frame_prompt=storyboard.frame_prompt,
frame_asset_ids=storyboard.frame_asset_ids,
frame_image_url=storyboard.frame_image_url,
shot_prompt=storyboard.shot_prompt,
shot_video_url=storyboard.shot_video_url
)
self.db.add(db_storyboard)
self.db.commit()
self.db.refresh(db_storyboard)
return self._to_storyboard_entity(db_storyboard)
async def get_storyboard_by_id(self, storyboard_id: int) -> Optional[Storyboard]:
"""根据ID获取分镜"""
db_storyboard = self.db.query(StoryboardModel).filter(StoryboardModel.id == storyboard_id).first() # type: ignore[arg-type]
if db_storyboard:
return self._to_storyboard_entity(db_storyboard)
return None
async def update_storyboard(self, storyboard_id: int, **kwargs) -> Optional[Storyboard]:
"""更新分镜"""
db_storyboard = self.db.query(StoryboardModel).filter(StoryboardModel.id == storyboard_id).first() # type: ignore[arg-type]
if db_storyboard:
for key, value in kwargs.items():
if hasattr(db_storyboard, key):
setattr(db_storyboard, key, value)
self.db.commit()
self.db.refresh(db_storyboard)
return self._to_storyboard_entity(db_storyboard)
return None
async def delete_storyboards_by_project_id(self, project_id: int) -> int:
"""删除项目的所有分镜,返回删除的数量"""
db_storyboards = self.db.query(StoryboardModel).filter(StoryboardModel.project_id == project_id).all() # type: ignore[arg-type]
count = len(db_storyboards)
for db_storyboard in db_storyboards:
self.db.delete(db_storyboard)
self.db.commit()
return count
# 转换方法
def _to_project_entity(self, db_project: ProjectModel) -> Project:
"""将数据库模型转换为项目领域实体"""
return Project(
id=db_project.id,
name=db_project.name,
script=db_project.script,
full_script=db_project.full_script,
prompt=db_project.prompt,
video_url=db_project.video_url,
created_at=db_project.created_at,
updated_at=db_project.updated_at,
deleted_at=db_project.deleted_at
)
def _to_asset_entity(self, db_asset: AssetModel) -> Asset:
"""将数据库模型转换为素材领域实体"""
return Asset(
id=db_asset.id,
project_id=db_asset.project_id,
name=db_asset.name,
original_url=db_asset.original_url,
description=db_asset.description,
tags=cast(Optional[List[str]], db_asset.tags),
created_at=db_asset.created_at,
updated_at=db_asset.updated_at
)
def _to_storyboard_entity(self, db_storyboard: StoryboardModel) -> Storyboard:
"""将数据库模型转换为分镜领域实体"""
return Storyboard(
id=db_storyboard.id,
project_id=db_storyboard.project_id,
frame_prompt=db_storyboard.frame_prompt,
shot_prompt=db_storyboard.shot_prompt,
frame_asset_ids=cast(Optional[List[int]], db_storyboard.frame_asset_ids),
frame_image_url=db_storyboard.frame_image_url,
shot_video_url=db_storyboard.shot_video_url,
created_at=db_storyboard.created_at,
updated_at=db_storyboard.updated_at
)

View File

@ -0,0 +1,75 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from ....domain.repositories.prompt_repository import PromptRepository
from ....domain.entities.prompt import Prompt
from ..models import Prompt as PromptModel
class PromptRepositoryImpl(PromptRepository):
"""提示词仓储实现"""
def __init__(self, db: Session):
self.db = db
async def get_all(self) -> List[Prompt]:
"""获取所有提示词"""
db_prompts = self.db.query(PromptModel).all()
return [self._to_entity(db_prompt) for db_prompt in db_prompts]
async def get_by_step_and_name(self, step: str, name: str) -> Optional[Prompt]:
"""根据step和name获取提示词"""
db_prompt = self.db.query(PromptModel).filter(
and_(PromptModel.step == step, PromptModel.name == name)
).first()
if db_prompt:
return self._to_entity(db_prompt)
return None
async def get_by_step(self, step: str) -> List[Prompt]:
"""根据step获取所有提示词"""
db_prompts = self.db.query(PromptModel).filter(PromptModel.step == step).all()
return [self._to_entity(db_prompt) for db_prompt in db_prompts]
async def save(self, prompt: Prompt) -> Prompt:
"""保存提示词(新增或更新)"""
if prompt.id:
# 更新现有提示词
db_prompt = self.db.query(PromptModel).filter(PromptModel.id == prompt.id).first()
if db_prompt:
db_prompt.step = prompt.step
db_prompt.name = prompt.name
db_prompt.prompt = prompt.prompt
else:
# 创建新提示词
db_prompt = PromptModel(
step=prompt.step,
name=prompt.name,
prompt=prompt.prompt
)
self.db.add(db_prompt)
self.db.commit()
self.db.refresh(db_prompt)
return self._to_entity(db_prompt)
async def delete(self, prompt_id: int) -> bool:
"""删除提示词"""
db_prompt = self.db.query(PromptModel).filter(PromptModel.id == prompt_id).first()
if db_prompt:
self.db.delete(db_prompt)
self.db.commit()
return True
return False
def _to_entity(self, db_prompt: PromptModel) -> Prompt:
"""将数据库模型转换为领域实体"""
return Prompt(
id=db_prompt.id,
step=db_prompt.step,
name=db_prompt.name,
prompt=db_prompt.prompt,
created_at=db_prompt.created_at,
updated_at=db_prompt.updated_at
)

View File

@ -0,0 +1,65 @@
from sqlalchemy import create_engine, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
from sqlalchemy.exc import DisconnectionError, OperationalError
from ..config import settings
import logging
import time
logger = logging.getLogger(__name__)
# 创建数据库引擎
engine = create_engine(
settings.database_url,
poolclass=QueuePool,
pool_size=settings.db_pool_size, # 连接池大小
max_overflow=settings.db_max_overflow, # 连接池溢出大小
pool_timeout=settings.db_pool_timeout, # 获取连接超时时间
pool_recycle=settings.db_pool_recycle, # 连接回收时间
pool_pre_ping=True, # 连接前ping测试
echo=False, # 生产环境设为False
connect_args={
"connect_timeout": settings.db_connect_timeout, # MySQL连接超时
"read_timeout": settings.db_connect_timeout, # MySQL读取超时
"write_timeout": settings.db_connect_timeout, # MySQL写入超时
} if "mysql" in settings.database_url.lower() else {}
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基础模型类
Base = declarative_base()
def get_db():
"""获取数据库会话的依赖函数,支持连接重试"""
max_retries = 3
retry_delay = 1
for attempt in range(max_retries):
db = SessionLocal()
try:
# 测试连接
db.execute(text("SELECT 1"))
yield db
return
except (DisconnectionError, OperationalError) as e:
logger.warning(f"数据库连接失败,尝试重连 ({attempt + 1}/{max_retries}): {str(e)}")
db.close()
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1)) # 递增延迟
continue
else:
logger.error(f"数据库连接重试失败,已达到最大重试次数: {str(e)}")
raise
except Exception as e:
logger.error(f"数据库会话创建失败: {str(e)}")
db.close()
raise
finally:
try:
db.close()
except Exception as e:
logger.warning(f"关闭数据库会话时出错: {str(e)}")

View File

@ -0,0 +1,304 @@
import base64
import io
import json
import time
import tempfile
import httpx
from typing import List, Optional, Union, Any, Dict
from PIL import Image
from google import genai
from google.genai import types
from ..config import settings
from ..utils import safe_json_loads
from loguru import logger
import ssl
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
client = genai.Client(api_key=settings.google_api_key)
class GeminiClient:
"""Gemini AI客户端"""
def generate_image_from_prompt(
self,
prompt: str,
reference_images: Optional[List[bytes]] = None
) -> Optional[bytes]:
"""
使用Gemini生成图片
Args:
prompt: 图片生成提示词
reference_images: 参考图片列表可选
Returns:
生成的图片二进制数据失败返回None
"""
try:
# 构建图片生成提示词
image_prompt = f'''
Generate an image strictly according to the following prompt without any confirmation, questioning, or omission:
{prompt}
'''
# 构建内容列表
contents: List[Any] = []
# 如果有参考图片,添加到内容中
if reference_images:
for img_bytes in reference_images:
# 将图片转换为PIL Image对象
img = Image.open(io.BytesIO(img_bytes))
contents.append(img)
contents.append(image_prompt)
# 调用Gemini API生成图片
response = client.models.generate_content(
model='gemini-2.5-flash-image-preview',
contents=contents,
config=types.GenerateContentConfig(
http_options=types.HttpOptions(timeout=30000)
)
)
text = None
image_base64 = None
if hasattr(response, 'candidates') and response.candidates:
candidate = response.candidates[0]
if hasattr(candidate, 'content') and candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if hasattr(part, 'text') and part.text:
text = part.text
if hasattr(part, 'inline_data') and part.inline_data and part.inline_data.data:
image_base64 = part.inline_data.data
if not image_base64:
raise Exception(text if text else "Gemini API未返回图片数据")
return base64.b64decode(image_base64)
except Exception as e:
logger.error(f"Gemini图片生成失败: {e}")
return None
def analyze_image(self, image_bytes: bytes) -> Optional[dict]:
"""
分析图片内容
Args:
image_bytes: 图片二进制数据
Returns:
分析结果字典包含namedescriptiontags
"""
try:
prompt = """
请分析这张图片并返回以下JSON格式的结果
{
"name": "图片名称",
"description": "详细描述",
"tags": ["标签1", "标签2", "标签3"]
}
请确保返回的是有效的JSON格式不要包含其他文字
"""
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=[
prompt,
types.Part.from_bytes(
data=image_bytes,
mime_type='image/jpeg',
)
]
)
# 解析返回的JSON
result_text = response.text
result = safe_json_loads(result_text)
return result
except Exception as e:
logger.error(f"Gemini图片分析失败: {e}")
return None
async def generate_video(
self,
frame_image_bytes: bytes,
shot_prompt: str
) -> Optional[bytes]:
"""
使用Google Veo-3.0生成视频
Args:
frame_image_bytes: 首帧图片二进制数据
shot_prompt: 视频生成提示词
duration_seconds: 视频时长
Returns:
生成的视频二进制数据失败返回None
"""
try:
# 构建视频生成提示词
video_prompt = f"Create a video with the following prompt: {shot_prompt}"
image_input = types.Image(image_bytes=frame_image_bytes, mime_type="image/jpeg")
# 调用Veo-3.0 API生成视频
operation = client.models.generate_videos(
model="veo-3.0-fast-generate-preview", #veo-3.0-generate-preview
prompt=video_prompt,
image=image_input
)
# 轮询操作状态直到视频生成完成
logger.info("等待视频生成完成...")
while not operation.done:
time.sleep(2)
operation = client.operations.get(operation)
# 下载生成的视频
video = None
if operation and operation.response and operation.response.generated_videos:
video = operation.response.generated_videos[0]
if video is None:
if operation and operation.response and operation.response.rai_media_filtered_reasons and len(operation.response.rai_media_filtered_reasons) > 0:
raise Exception(operation.response.rai_media_filtered_reasons[0])
raise Exception("未知错误")
video_bytes = client.files.download(file=video.video)
logger.info("Veo-3.0视频生成成功")
return video_bytes
except Exception as e:
logger.error(f"Veo-3.0视频生成失败: {e}")
raise e
async def analyze_video(self, video_url: str) -> Optional[Dict[str, Any]]:
"""
分析视频内容提取关键素材帧和分镜关键帧
Args:
video_url: 视频URL
Returns:
分析结果字典包含key_assets_frames和key_storyboard_frames
"""
try:
# 下载视频文件到临时文件
async with httpx.AsyncClient() as http_client:
response = await http_client.get(video_url, timeout=60.0)
if response.status_code != 200:
raise Exception(f"下载视频失败,状态码: {response.status_code}")
video_bytes = response.content
logger.info(f"成功下载视频,大小: {len(video_bytes)} 字节")
# 创建临时文件
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
temp_file.write(video_bytes)
temp_video_path = temp_file.name
try:
# 上传视频文件到Gemini
logger.info("正在上传视频到Gemini...")
myfile = client.files.upload(file=temp_video_path)
logger.info(f"视频上传成功文件ID: {myfile.name}")
# 等待文件变为ACTIVE状态
logger.info("等待文件处理完成...")
max_wait_time = 300 # 最多等待5分钟
wait_interval = 5 # 每5秒检查一次
waited_time = 0
while waited_time < max_wait_time:
file_info = client.files.get(name=myfile.name)
logger.info(f"文件状态: {file_info.state}")
if file_info.state == "ACTIVE":
logger.info("文件已准备就绪,开始分析")
break
elif file_info.state == "FAILED":
raise Exception("文件处理失败")
time.sleep(wait_interval)
waited_time += wait_interval
if waited_time >= max_wait_time:
raise Exception("文件处理超时")
# 构建分析提示词
analysis_prompt = """
请仔细分析这个视频并返回以下JSON格式的结果
{
"title": "为这个视频生成一个简洁有吸引力的标题不超过20个字符",
"script": "根据视频内容生成的完整剧本,包含对话、动作、场景描述等",
"key_assets_frames": [
{
"timestamp": "HH:MM:SS",
"name": "素材名称",
"description": "素材描述",
"tags": ["标签1", "标签2"]
}
],
"key_storyboard_frames": [
{
"timestamp": "HH:MM:SS",
"frame_prompt": "该帧画面描述",
"shot_prompt": "该关键帧到下一关键帧之间的剧情描述"
}
]
}
要求
1. title: 根据视频主题和内容生成简洁有吸引力的标题要能概括视频核心内容不超过20个字符
2. script: 根据视频内容生成完整的剧本包含场景描述角色对话动作指导等要生动详细
3. key_assets_frames: 提取3-5个关键素材帧包含视觉元素如角色场景道具动物等
4. key_storyboard_frames: 提取分镜关键帧约每8秒一帧
5. timestamp格式必须是HH:MM:SS
6. 确保返回的是有效的JSON格式不要包含其他文字
"""
# 调用Gemini分析视频
logger.info("正在分析视频内容...")
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[myfile, analysis_prompt],
config=types.GenerateContentConfig(
http_options=types.HttpOptions(timeout=120000) # 2分钟超时
)
)
# 解析返回的JSON
result_text = response.text
logger.info(f"Gemini分析结果: {result_text}")
result = safe_json_loads(result_text)
if not result:
raise Exception("无法解析Gemini返回的JSON结果")
# 验证结果结构
if "script" not in result or "key_assets_frames" not in result or "key_storyboard_frames" not in result:
raise Exception("Gemini返回的结果格式不正确")
logger.info(f"视频分析成功,生成剧本长度: {len(result['script'])} 字符,提取了 {len(result['key_assets_frames'])} 个素材帧和 {len(result['key_storyboard_frames'])} 个分镜帧")
return result
finally:
# 清理临时文件
try:
import os
os.unlink(temp_video_path)
except:
pass
except Exception as e:
logger.error(f"视频分析失败: {e}")
return None

View File

@ -0,0 +1,186 @@
import json
import httpx
from typing import Optional, List, Dict, Any
from ..config import settings
from ..utils import safe_json_loads
from ..services.template_service import TemplateService
from loguru import logger
class OpenRouterClient:
"""OpenRouter客户端"""
def __init__(self):
self.api_key = settings.openrouter_api_key
self.base_url = settings.openrouter_base_url
self.headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
async def generate_text(
self,
prompt: str,
model: str = "anthropic/claude-3.5-sonnet"
) -> Optional[str]:
"""
生成文本内容
Args:
prompt: 提示词
model: 使用的模型名称
Returns:
生成的文本内容失败返回None
"""
try:
payload = {
"model": model,
"messages": [
{"role": "user", "content": prompt}
],
"max_tokens": 4000,
"temperature": 0.7
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/chat/completions",
headers=self.headers,
json=payload,
timeout=60.0
)
if response.status_code == 200:
result = response.json()
return result["choices"][0]["message"]["content"]
else:
logger.error(f"OpenRouter API调用失败: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"OpenRouter文本生成失败: {e}")
return None
async def generate_script_and_assets(
self,
prompt_template: str,
script_or_idea: str,
model: str = "anthropic/claude-3.5-sonnet"
) -> Optional[Dict[str, Any]]:
"""
生成完整剧本和素材信息
Args:
prompt_template: 提示词模板包含${script_or_idea}占位符
script_or_idea: 剧本或创意内容
model: 使用的模型名称
Returns:
包含full_script和assets的字典
"""
try:
# 替换模板变量
template_variables = {
"script_or_idea": script_or_idea
}
prompt = TemplateService.replace_template_variables(prompt_template, template_variables)
result_text = await self.generate_text(prompt, model)
if result_text:
# 尝试解析JSON
result = safe_json_loads(result_text)
if result and "full_script" in result and "assets" in result:
return result
else:
logger.error(f"OpenRouter返回的结果格式不正确: {result_text}")
return None
return None
except Exception as e:
logger.error(f"OpenRouter生成剧本和素材失败: {e}")
return None
async def translate_text(
self,
text: str,
source_lang: str = "zh",
target_lang: str = "en",
model: str = "anthropic/claude-3.5-sonnet"
) -> Optional[str]:
"""
翻译文本
Args:
text: 要翻译的文本
source_lang: 源语言代码默认为中文(zh)
target_lang: 目标语言代码默认为英文(en)
model: 使用的模型名称
Returns:
翻译后的文本失败返回None
"""
try:
# 构建翻译提示词
translate_prompt = f"""
请将以下{source_lang}文本翻译成{target_lang}保持原意和语境只返回翻译结果不要包含其他解释
{text}
"""
result = await self.generate_text(translate_prompt, model)
if result:
# 去除可能的引号和多余空白
result = result.strip().strip('"\'')
logger.info(f"翻译成功: {text[:50]}... -> {result[:50]}...")
return result
else:
logger.error(f"翻译失败: {text}")
return None
except Exception as e:
logger.error(f"翻译过程出错: {e}")
return None
async def generate_storyboards(
self,
prompt_template: str,
materials_description: str,
script: str
) -> Optional[List[Dict[str, Any]]]:
"""
生成分镜内容
Args:
prompt_template: 提示词模板包含${materials_description}${script}占位符
materials_description: 素材描述
script: 用户剧本
Returns:
分镜列表每个包含frame_prompt, frame_asset_ids, shot_prompt
"""
try:
# 替换模板变量
template_variables = {
"materials_description": materials_description,
"script": script
}
full_prompt = TemplateService.replace_template_variables(prompt_template, template_variables)
result_text = await self.generate_text(full_prompt)
if result_text:
# 尝试解析JSON
result = safe_json_loads(result_text)
if result is not None:
if isinstance(result, list):
return result
else:
logger.error("OpenRouter返回的不是数组格式")
return None
return None
except Exception as e:
logger.error(f"分镜生成失败: {e}")
return None

View File

@ -0,0 +1,215 @@
import time
import qiniu
import requests
import subprocess
import tempfile
import os
from typing import Optional, List
from ..config import settings
from loguru import logger
class QiniuClient:
"""七牛云客户端"""
def __init__(self):
self.access_key = settings.qiniu_access_key
self.secret_key = settings.qiniu_secret_key
self.bucket_name = settings.qiniu_bucket_name
self.domain = settings.qiniu_domain
self.auth = qiniu.Auth(self.access_key, self.secret_key)
def get_upload_token(self) -> Optional[str]:
"""
获取上传token
Returns:
上传token失败返回None
"""
try:
token = self.auth.upload_token(self.bucket_name)
return token
except Exception as e:
logger.error(f"获取七牛云上传token失败: {e}")
return None
def upload_file(self, file_bytes: bytes, suffix: str) -> Optional[str]:
"""
上传文件到七牛云
Args:
file_bytes: 文件二进制数据
suffix: 文件扩展名
Returns:
文件URL失败返回None
"""
try:
token = self.auth.upload_token(self.bucket_name)
filename = f"{int(time.time())}_{hash(file_bytes) % 10000}.{suffix}"
# 创建上传管理器
upload_mgr = qiniu.put_data(token, filename, file_bytes)
# 执行上传
ret, info = upload_mgr
if ret is None:
logger.error(f"上传到七牛云失败: {info}")
return None
# 返回完整的文件URL
return f"https://{self.domain}/{filename}"
except Exception as e:
logger.error(f"上传到七牛云失败: {e}")
return None
def get_upload_info(self) -> dict:
"""
获取上传相关信息
Returns:
包含tokendomainbucket的字典
"""
token = self.get_upload_token()
return {
"token": token,
"domain": f"https://{self.domain}",
"bucket": self.bucket_name
}
def merge_videos(self, video_urls: List[str]) -> Optional[str]:
"""
合并多个视频文件并上传到七牛云
Args:
video_urls: 视频URL列表
Returns:
合并后的视频URL失败返回None
"""
if not video_urls:
logger.error("视频URL列表为空")
return None
if len(video_urls) == 1:
return video_urls[0]
try:
# 创建临时目录
with tempfile.TemporaryDirectory() as temp_dir:
video_files = []
# 下载所有视频文件
for i, url in enumerate(video_urls):
try:
response = requests.get(url, timeout=60.0)
if response.status_code == 200:
video_file = os.path.join(temp_dir, f"video_{i}.mp4")
with open(video_file, 'wb') as f:
f.write(response.content)
video_files.append(video_file)
logger.info(f"成功下载视频: {url}")
else:
logger.error(f"下载视频失败: {url}, 状态码: {response.status_code}")
except Exception as e:
logger.error(f"下载视频失败: {url}, 错误: {e}")
continue
if not video_files:
logger.error("没有成功下载任何视频文件")
return None
# 创建ffmpeg输入文件列表
input_list_file = os.path.join(temp_dir, "input_list.txt")
with open(input_list_file, 'w') as f:
for video_file in video_files:
f.write(f"file '{video_file}'\n")
# 输出文件路径
output_file = os.path.join(temp_dir, "merged_video.mp4")
# 使用ffmpeg合并视频
# 尝试不同的FFmpeg路径
possible_ffmpeg_paths = [
'ffmpeg', # 系统PATH中
'C:\\Users\\LK\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe', # 常见安装位置
]
ffmpeg_found = False
result = None
for ffmpeg_path in possible_ffmpeg_paths:
try:
ffmpeg_cmd = [
ffmpeg_path,
'-f', 'concat',
'-safe', '0',
'-i', input_list_file,
'-c', 'copy',
'-y', # 覆盖输出文件
output_file
]
logger.info(f"尝试执行FFmpeg命令: {' '.join(ffmpeg_cmd)}")
result = subprocess.run(
ffmpeg_cmd,
capture_output=True,
text=True,
timeout=300 # 5分钟超时
)
ffmpeg_found = True
logger.info(f"使用FFmpeg路径: {ffmpeg_path}")
logger.info(f"FFmpeg返回码: {result.returncode}")
if result.stdout:
logger.debug(f"FFmpeg输出: {result.stdout}")
if result.stderr:
logger.debug(f"FFmpeg错误: {result.stderr}")
break
except FileNotFoundError as e:
logger.debug(f"FFmpeg路径不存在: {ffmpeg_path} - {e}")
continue
except subprocess.TimeoutExpired:
logger.error(f"FFmpeg执行超时: {ffmpeg_path}")
continue
except Exception as e:
logger.error(f"FFmpeg路径 {ffmpeg_path} 执行失败: {e}")
# 如果是第一个路径系统PATH显示更详细的错误
if ffmpeg_path == 'ffmpeg':
logger.error(f"FFmpeg命令执行错误请检查安装是否正确")
continue
if not ffmpeg_found:
logger.error("未找到FFmpeg请确保已安装FFmpeg并配置到PATH中")
logger.error("Windows安装FFmpeg方法")
logger.error("1. 使用Chocolatey: choco install ffmpeg")
logger.error("2. 使用winget: winget install Gyan.FFmpeg")
logger.error("3. 手动下载并添加到PATH: https://www.gyan.dev/ffmpeg/builds/")
return None
if result.returncode == 0:
# 读取合并后的视频文件
with open(output_file, 'rb') as f:
merged_video_bytes = f.read()
logger.info(f"成功合并 {len(video_files)} 个视频文件,大小: {len(merged_video_bytes)} 字节")
# 直接上传到七牛云
video_url = self.upload_file(merged_video_bytes, "mp4")
if video_url:
logger.info(f"合并视频上传成功: {video_url}")
return video_url
else:
logger.error("合并视频上传到七牛云失败")
return None
else:
logger.error(f"ffmpeg合并视频失败: {result.stderr}")
return None
except Exception as e:
logger.error(f"视频合并过程失败: {e}")
return None

View File

@ -0,0 +1,92 @@
import re
from typing import Dict, Any, Optional
from loguru import logger
class TemplateService:
"""提示词模板替换服务"""
@staticmethod
def replace_template_variables(template: str, variables: Dict[str, Any]) -> str:
"""
替换模板中的占位符变量
Args:
template: 包含${param}格式占位符的模板字符串
variables: 变量字典键为参数名值为替换内容
Returns:
替换后的字符串
"""
if not template:
return ""
if not variables:
return template
try:
# 使用正则表达式查找所有${param}格式的占位符
pattern = r'\$\{([^}]+)\}'
def replace_func(match):
param_name = match.group(1)
if param_name in variables:
value = variables[param_name]
# 如果值是None替换为空字符串
if value is None:
return ""
return str(value)
else:
# 如果找不到对应的变量,保留原占位符
logger.warning(f"模板变量 '{param_name}' 未找到,保留原占位符")
return match.group(0)
result = re.sub(pattern, replace_func, template)
return result
except Exception as e:
logger.error(f"模板替换失败: {e}")
return template
@staticmethod
def extract_template_variables(template: str) -> list[str]:
"""
提取模板中的所有变量名
Args:
template: 包含${param}格式占位符的模板字符串
Returns:
变量名列表
"""
if not template:
return []
try:
pattern = r'\$\{([^}]+)\}'
matches = re.findall(pattern, template)
return list(set(matches)) # 去重
except Exception as e:
logger.error(f"提取模板变量失败: {e}")
return []
@staticmethod
def validate_template_variables(template: str, variables: Dict[str, Any]) -> Optional[str]:
"""
验证模板变量是否完整
Args:
template: 包含${param}格式占位符的模板字符串
variables: 变量字典
Returns:
如果验证失败返回错误信息否则返回None
"""
required_vars = TemplateService.extract_template_variables(template)
missing_vars = [var for var in required_vars if var not in variables]
if missing_vars:
return f"缺少必需的模板变量: {', '.join(missing_vars)}"
return None

View File

@ -0,0 +1,150 @@
import subprocess
import tempfile
import os
import httpx
from typing import Optional, List, Tuple
from loguru import logger
class VideoService:
"""视频处理服务"""
async def extract_frame_at_timestamp(
self,
video_url: str,
timestamp: str
) -> Optional[bytes]:
"""
根据时间戳从视频中提取帧
Args:
video_url: 视频URL
timestamp: 时间戳格式为HH:MM:SS
Returns:
提取的帧图片二进制数据失败返回None
"""
try:
# 下载视频文件到临时文件
async with httpx.AsyncClient() as http_client:
response = await http_client.get(video_url, timeout=60.0)
if response.status_code != 200:
raise Exception(f"下载视频失败,状态码: {response.status_code}")
video_bytes = response.content
logger.info(f"成功下载视频,大小: {len(video_bytes)} 字节")
# 创建临时视频文件
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_video_file:
temp_video_file.write(video_bytes)
temp_video_path = temp_video_file.name
# 创建临时输出图片文件
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_image_file:
temp_image_path = temp_image_file.name
try:
# 使用ffmpeg提取指定时间戳的帧
# 尝试不同的FFmpeg路径
possible_ffmpeg_paths = [
'ffmpeg', # 系统PATH中
'C:\\Users\\LK\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe', # 常见安装位置
]
ffmpeg_found = False
result = None
for ffmpeg_path in possible_ffmpeg_paths:
try:
ffmpeg_cmd = [
ffmpeg_path,
'-i', temp_video_path,
'-ss', timestamp, # 跳转到指定时间戳
'-vframes', '1', # 只提取一帧
'-y', # 覆盖输出文件
temp_image_path
]
logger.info(f"尝试执行FFmpeg命令: {' '.join(ffmpeg_cmd)}")
result = subprocess.run(
ffmpeg_cmd,
capture_output=True,
text=True,
timeout=30 # 30秒超时
)
ffmpeg_found = True
logger.info(f"使用FFmpeg路径: {ffmpeg_path}")
logger.info(f"FFmpeg返回码: {result.returncode}")
if result.stdout:
logger.debug(f"FFmpeg输出: {result.stdout}")
if result.stderr:
logger.debug(f"FFmpeg错误: {result.stderr}")
break
except FileNotFoundError:
logger.debug(f"FFmpeg路径不存在: {ffmpeg_path}")
continue
except subprocess.TimeoutExpired:
logger.error(f"FFmpeg执行超时: {ffmpeg_path}")
continue
except Exception as e:
logger.error(f"FFmpeg路径 {ffmpeg_path} 执行失败: {e}")
continue
if not ffmpeg_found:
logger.error("未找到FFmpeg请确保已安装FFmpeg并配置到PATH中")
return None
if result.returncode == 0:
# 读取提取的帧图片
if os.path.exists(temp_image_path):
with open(temp_image_path, 'rb') as f:
frame_bytes = f.read()
logger.info(f"成功提取时间戳 {timestamp} 的帧,大小: {len(frame_bytes)} 字节")
return frame_bytes
else:
logger.error(f"未找到提取的帧文件: {temp_image_path}")
return None
else:
logger.error(f"ffmpeg提取帧失败: {result.stderr}")
return None
finally:
# 清理临时文件
try:
os.unlink(temp_video_path)
except:
pass
try:
os.unlink(temp_image_path)
except:
pass
except Exception as e:
logger.error(f"提取视频帧失败: {e}")
return None
async def extract_multiple_frames(
self,
video_url: str,
timestamps: List[str]
) -> List[Tuple[str, Optional[bytes]]]:
"""
根据多个时间戳从视频中批量提取帧
Args:
video_url: 视频URL
timestamps: 时间戳列表格式为HH:MM:SS
Returns:
(timestamp, frame_bytes)元组列表
"""
results = []
for timestamp in timestamps:
frame_bytes = await self.extract_frame_at_timestamp(video_url, timestamp)
results.append((timestamp, frame_bytes))
return results

View File

@ -0,0 +1,60 @@
import re
import json
from typing import Optional, Union, Any
from loguru import logger
def clean_json_markdown(text: str) -> str:
"""
清理文本中的markdown格式提取纯JSON内容
Args:
text: 包含markdown格式的文本
Returns:
清理后的JSON字符串
"""
if not text:
return text
# 移除markdown代码块格式 ```json ... ``` 或 ``` ... ```
# 支持多种情况:```json、```JSON、```
pattern = r'```(?:json|JSON)?\s*(.*?)\s*```'
match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
if match:
# 如果找到markdown格式提取其中的内容
cleaned_text = match.group(1).strip()
else:
# 如果没有找到markdown格式直接使用原文本
cleaned_text = text.strip()
return cleaned_text
def safe_json_loads(text: str) -> Optional[Union[dict, list, Any]]:
"""
安全地解析JSON字符串自动清理markdown格式
Args:
text: 包含JSON的文本字符串
Returns:
解析后的Python对象失败返回None
"""
if not text:
return None
try:
# 先清理markdown格式
cleaned_text = clean_json_markdown(text)
# 尝试解析JSON
return json.loads(cleaned_text)
except json.JSONDecodeError as e:
logger.error(f"JSON解析失败: {e}, 原文本: {text[:200]}...")
return None
except Exception as e:
logger.error(f"JSON处理异常: {e}")
return None

143
src/main.py Normal file
View File

@ -0,0 +1,143 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
import sys
import warnings
# 屏蔽来自pydantic关于Operation字段名遮蔽的依赖警告
warnings.filterwarnings(
"ignore",
message=r'Field name ".*" shadows an attribute in parent "Operation";?',
category=UserWarning,
module=r"pydantic\._internal\._fields",
)
# 配置日志
logger.remove() # 移除默认处理器
# 控制台输出 - 使用更简单的格式
logger.add(
sys.stdout,
level="DEBUG",
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
colorize=True
)
# 文件输出
try:
logger.add(
"logs/app.log",
rotation="100 MB",
level="DEBUG",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
encoding="utf-8"
)
logger.info("日志文件配置成功")
except Exception as e:
logger.error(f"日志文件配置失败: {e}")
# 测试日志是否正常工作
logger.info("应用启动,日志系统初始化完成")
# 创建FastAPI应用
app = FastAPI(
title="AI视频分镜生成器",
description="基于AI的视频分镜生成系统支持提示词管理、项目管理、素材分析、分镜生成等功能",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 导入路由
from .api.routes import prompts, projects, qiniu
# 注册路由
app.include_router(prompts.router)
app.include_router(projects.router)
app.include_router(qiniu.router)
@app.get("/")
async def root():
"""根路径"""
logger.info("根路径被访问")
return {
"message": "AI视频分镜生成器API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
"""健康检查"""
logger.debug("DEBUG: 健康检查被调用")
logger.info("INFO: 健康检查被调用")
logger.warning("WARNING: 这是一个警告日志测试")
logger.error("ERROR: 这是一个错误日志测试")
logger.critical("CRITICAL: 这是一个严重错误日志测试")
return {"status": "healthy", "message": "日志测试完成"}
@app.post("/test/merge-videos")
async def test_merge_videos():
"""测试视频合成功能"""
from fastapi import HTTPException
test_video_urls = [
"https://cdn.qikongjian.com/1756381450_7670.mp4",
"https://cdn.qikongjian.com/1756381672_5379.mp4",
"https://cdn.qikongjian.com/1756383519_1716.mp4"
]
try:
from .api.dependencies import StorageServiceImpl
from .infrastructure.external.qiniu_client import QiniuClient
# 直接创建服务实例
qiniu_client = QiniuClient()
storage_service = StorageServiceImpl(qiniu_client)
logger.info(f"开始测试视频合成,视频列表: {test_video_urls}")
# 测试视频合成直接返回上传后的URL
video_url = storage_service.merge_videos(test_video_urls)
if video_url:
logger.info(f"视频合成并上传成功: {video_url}")
return {
"success": True,
"message": "视频合成测试成功",
"video_url": video_url,
"test_videos": test_video_urls
}
else:
logger.error("视频合成失败")
return {
"success": False,
"message": "视频合成失败",
"test_videos": test_video_urls
}
except Exception as e:
logger.error(f"测试视频合成失败: {e}")
raise HTTPException(status_code=500, detail=f"测试视频合成失败: {str(e)}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)

1071
swagger.json Normal file

File diff suppressed because it is too large Load Diff