[refactor] 重构 backend 分层并补齐测试与文档
This commit is contained in:
1 parent
ae85d0e0ca
commit
e33690d2ba
88 files changed
+12269
-6519
No files matched your search
+129
-4
@@ -36,6 +36,52 @@ backend/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 当前分层约定
|
||||
|
||||
目前后端已经按“**路由层瘦身,业务逻辑下沉**”的方向整理过一轮,建议后续继续遵循下面这套分工:
|
||||
|
||||
- `app/api/v1/routes/`
|
||||
- 负责收参、依赖注入、调用 service、包装响应
|
||||
- `app/services/common/`
|
||||
- 负责通用校验、通用 CRUD、公共错误文案
|
||||
- `app/services/studio/`
|
||||
- 负责项目、章节、分镜、文件、图片任务等 Studio 主业务
|
||||
- `app/services/llm/`
|
||||
- 负责 Provider / Model / Settings 管理逻辑
|
||||
- `app/services/film/`
|
||||
- 负责视频生成、分镜帧提示词任务等编排逻辑
|
||||
|
||||
推荐遵循的判断标准:
|
||||
|
||||
- **留在 route 的**
|
||||
- 参数解析
|
||||
- `Depends`
|
||||
- 调 service
|
||||
- 返回 `ApiResponse`
|
||||
- **放到 service 的**
|
||||
- 跨实体校验
|
||||
- 默认值策略
|
||||
- 文件/存储交互
|
||||
- 任务编排
|
||||
- AI 工作流调用
|
||||
|
||||
## 响应与错误约定
|
||||
|
||||
当前接口已经统一使用 `ApiResponse` 响应壳,后续新增接口也建议保持一致:
|
||||
|
||||
- 创建成功:`created_response(...)`
|
||||
- 普通成功:`success_response(...)`
|
||||
- 空响应成功:`empty_response()`
|
||||
- 分页成功:`paginated_response(...)`
|
||||
|
||||
错误风格也已开始统一,优先复用:
|
||||
|
||||
- `entity_not_found(...)`
|
||||
- `entity_already_exists(...)`
|
||||
- `required_field(...)`
|
||||
- `invalid_choice(...)`
|
||||
- `not_belong_to(...)`
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 uv(若未安装)
|
||||
@@ -117,7 +163,9 @@ uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
| `uv add <pkg>` | 添加依赖 |
|
||||
| `uv run uvicorn app.main:app --reload` | 开发运行 |
|
||||
| `uv run pytest` | 运行测试 |
|
||||
| `uv run pytest tests/... -q` | 运行定向测试 |
|
||||
| `uv run pylint app` | 对 `app` 包运行 Pylint(需已 `uv sync --group dev`) |
|
||||
| `python -m py_compile <paths>` | 快速做语法检查 |
|
||||
|
||||
## 代码检查(Pylint)
|
||||
|
||||
@@ -135,15 +183,92 @@ uv run pylint app
|
||||
|
||||
## 测试
|
||||
|
||||
### 单元测试与集成测试(Mock)
|
||||
### 推荐入口
|
||||
|
||||
- **集成测试(Mock)**:`tests/test_skills_integration.py` 中的 `TestAppIntegration`,覆盖 FastAPI 健康检查与示例路由。
|
||||
现在无论你在仓库根目录还是 `backend/` 目录运行,pytest 都已经做了统一配置。
|
||||
|
||||
在 `backend/` 目录:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_skills_integration.py -v
|
||||
cd backend
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
真实 LLM 测试前请安装可选依赖:`uv sync --group dev`(含 `langchain-openai`)。
|
||||
在仓库根目录:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests -q
|
||||
```
|
||||
|
||||
### 常用定向测试
|
||||
|
||||
运行 service 层测试:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest \
|
||||
tests/test_common_services.py \
|
||||
tests/test_llm_manage.py \
|
||||
tests/test_shot_character_links.py \
|
||||
tests/test_script_division.py \
|
||||
tests/test_studio_files_service.py \
|
||||
tests/test_generated_video_service.py \
|
||||
tests/test_shot_frame_prompt_tasks_service.py \
|
||||
tests/test_image_task_services.py \
|
||||
-q
|
||||
```
|
||||
|
||||
运行 API 响应壳测试:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest \
|
||||
tests/test_api_response_envelopes.py \
|
||||
tests/test_llm_api_responses.py \
|
||||
tests/test_studio_prompts_categories.py \
|
||||
tests/test_studio_api_responses.py \
|
||||
tests/test_shot_subresource_api_responses.py \
|
||||
tests/test_files_api_responses.py \
|
||||
tests/test_task_status_api_responses.py \
|
||||
tests/test_shot_character_links_api_responses.py \
|
||||
tests/test_image_tasks_api_responses.py \
|
||||
tests/test_generated_video_api_responses.py \
|
||||
tests/test_tasks_images_api_responses.py \
|
||||
-q
|
||||
```
|
||||
|
||||
### 当前测试重点
|
||||
|
||||
当前测试主要覆盖三类目标:
|
||||
|
||||
- **公共层**
|
||||
- 通用校验、通用 CRUD、公共错误文案
|
||||
- **service 层**
|
||||
- `studio`、`llm`、`film` 的关键业务服务
|
||||
- **API 层**
|
||||
- 创建/删除/异常时的 `ApiResponse` 外层结构
|
||||
|
||||
如果只是快速验证本轮改动,建议优先:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest tests/test_common_services.py tests/test_studio_api_responses.py -q
|
||||
```
|
||||
|
||||
### 语法级快速检查
|
||||
|
||||
在做较大重构但暂时不想跑全量测试时,可以先做一轮语法检查:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m py_compile $(rg --files app tests)
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- 根目录 `pytest.ini` 已补齐,避免从仓库根目录执行时异步测试被误跳过
|
||||
- 异步测试统一使用 `@pytest.mark.asyncio`
|
||||
- 真实 LLM 联调前,请先执行 `uv sync --group dev`
|
||||
|
||||
## 扩展说明
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import Chapter, Project, Shot
|
||||
from app.services.common import entity_not_found
|
||||
from app.models.task_links import GenerationTaskLink
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
|
||||
@@ -143,7 +144,7 @@ async def bind_task(
|
||||
"""
|
||||
if target_type == "project":
|
||||
if await db.get(Project, target_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Project"))
|
||||
db.add(
|
||||
GenerationTaskLink(
|
||||
task_id=task_id,
|
||||
@@ -155,7 +156,7 @@ async def bind_task(
|
||||
return
|
||||
if target_type == "chapter":
|
||||
if await db.get(Chapter, target_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Chapter not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Chapter"))
|
||||
db.add(
|
||||
GenerationTaskLink(
|
||||
task_id=task_id,
|
||||
@@ -167,7 +168,7 @@ async def bind_task(
|
||||
return
|
||||
if target_type == "shot":
|
||||
if await db.get(Shot, target_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Shot"))
|
||||
db.add(
|
||||
GenerationTaskLink(
|
||||
task_id=task_id,
|
||||
@@ -178,4 +179,3 @@ async def bind_task(
|
||||
)
|
||||
return
|
||||
raise HTTPException(status_code=400, detail="Invalid bind target type")
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import storage
|
||||
from app.core.db import async_session_maker
|
||||
from app.core.task_manager import DeliveryMode, SqlAlchemyTaskStore, TaskManager
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
from app.core.tasks import (
|
||||
ProviderConfig,
|
||||
VideoGenerationInput,
|
||||
VideoGenerationResult,
|
||||
VideoGenerationTask,
|
||||
)
|
||||
from app.dependencies import get_db
|
||||
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.models.task_links import GenerationTaskLink
|
||||
from app.models.studio import FileItem, Shot, ShotDetail, ShotFrameImage, ShotFrameType
|
||||
from app.models.types import FileUsageKind
|
||||
from app.services.studio.file_usages import sync_usage_from_shot_context
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.utils.files import create_file_from_url_or_b64
|
||||
from app.services.film.generated_video import build_run_args, preview_prompt_and_images, run_video_generation_task
|
||||
from app.schemas.common import ApiResponse, created_response, success_response
|
||||
|
||||
from .common import TaskCreated, _CreateOnlyTask
|
||||
from .video_request import VideoGenerationTaskRequest
|
||||
@@ -38,239 +23,6 @@ class VideoPromptPreviewResponse(BaseModel):
|
||||
images: list[str] = Field(default_factory=list, description="关联参考图 file_id 列表")
|
||||
|
||||
|
||||
REQUIRED_FRAMES_BY_MODE: dict[str, tuple[ShotFrameType, ...]] = {
|
||||
"first": (ShotFrameType.first,),
|
||||
"last": (ShotFrameType.last,),
|
||||
"key": (ShotFrameType.key,),
|
||||
"first_last": (ShotFrameType.first, ShotFrameType.last),
|
||||
"first_last_key": (ShotFrameType.first, ShotFrameType.last, ShotFrameType.key),
|
||||
"text_only": (),
|
||||
}
|
||||
|
||||
|
||||
def _required_image_count(reference_mode: str) -> int:
|
||||
return len(REQUIRED_FRAMES_BY_MODE[reference_mode])
|
||||
|
||||
|
||||
def _validate_images_count(reference_mode: str, images: list[str]) -> None:
|
||||
expected = _required_image_count(reference_mode)
|
||||
actual = len(images or [])
|
||||
if actual != expected:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"reference_mode={reference_mode} requires exactly {expected} images, got {actual}",
|
||||
)
|
||||
|
||||
|
||||
async def _validate_shot_and_duration(db: AsyncSession, shot_id: str) -> ShotDetail:
|
||||
shot = await db.get(Shot, shot_id)
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
shot_detail = await db.get(ShotDetail, shot_id)
|
||||
if shot_detail is None:
|
||||
raise HTTPException(status_code=404, detail="Shot detail not found")
|
||||
if shot_detail.duration is None or shot_detail.duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Shot duration is not configured; please set shot duration first")
|
||||
return shot_detail
|
||||
|
||||
|
||||
async def _file_id_to_data_url(db: AsyncSession, *, file_id: str) -> str:
|
||||
file_obj = await db.get(FileItem, file_id)
|
||||
if file_obj is None or not file_obj.storage_key:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}")
|
||||
try:
|
||||
content = await storage.download_file(key=file_obj.storage_key)
|
||||
except Exception: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}") from None
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}")
|
||||
|
||||
content_type: str | None = None
|
||||
try:
|
||||
info = await storage.get_file_info(key=file_obj.storage_key)
|
||||
content_type = (info.content_type or "").strip().lower() or None
|
||||
except Exception: # noqa: BLE001
|
||||
content_type = None
|
||||
if not content_type:
|
||||
guessed_type, _ = mimetypes.guess_type(file_obj.storage_key)
|
||||
content_type = (guessed_type or "").strip().lower() or None
|
||||
if not content_type or not content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}")
|
||||
|
||||
image_format = content_type.split("/", 1)[1].split(";", 1)[0].strip().lower() or "png"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
return f"data:image/{image_format};base64,{encoded}"
|
||||
|
||||
|
||||
async def _preview_prompt_and_images_by_old_logic(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
reference_mode: str,
|
||||
prompt: str | None,
|
||||
) -> tuple[str, list[str], ShotDetail]:
|
||||
shot_detail = await _validate_shot_and_duration(db, shot_id)
|
||||
required_frames = REQUIRED_FRAMES_BY_MODE[reference_mode]
|
||||
|
||||
frame_map: dict[ShotFrameType, ShotFrameImage] = {}
|
||||
if required_frames:
|
||||
stmt = select(ShotFrameImage).where(
|
||||
ShotFrameImage.shot_detail_id == shot_id,
|
||||
ShotFrameImage.frame_type.in_(required_frames),
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
frame_map = {r.frame_type: r for r in rows}
|
||||
|
||||
missing: list[ShotFrameType] = []
|
||||
for ft in required_frames:
|
||||
row = frame_map.get(ft)
|
||||
if row is None or not row.file_id:
|
||||
missing.append(ft)
|
||||
if missing:
|
||||
missing_name = ",".join(m.value for m in missing)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Required frame image is missing: {missing_name}; please generate it first",
|
||||
)
|
||||
|
||||
prompt_by_mode = {
|
||||
"first": (shot_detail.first_frame_prompt or "").strip(),
|
||||
"last": (shot_detail.last_frame_prompt or "").strip(),
|
||||
"key": (shot_detail.key_frame_prompt or "").strip(),
|
||||
"first_last": "\n".join(
|
||||
p
|
||||
for p in [
|
||||
(shot_detail.first_frame_prompt or "").strip(),
|
||||
(shot_detail.last_frame_prompt or "").strip(),
|
||||
]
|
||||
if p
|
||||
),
|
||||
"first_last_key": "\n".join(
|
||||
p
|
||||
for p in [
|
||||
(shot_detail.first_frame_prompt or "").strip(),
|
||||
(shot_detail.last_frame_prompt or "").strip(),
|
||||
(shot_detail.key_frame_prompt or "").strip(),
|
||||
]
|
||||
if p
|
||||
),
|
||||
"text_only": "",
|
||||
}
|
||||
final_prompt = (prompt or "").strip() or prompt_by_mode[reference_mode]
|
||||
if reference_mode == "text_only" and not final_prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required when reference_mode=text_only")
|
||||
|
||||
image_ids = [str(frame_map[ft].file_id) for ft in required_frames if frame_map.get(ft) and frame_map[ft].file_id]
|
||||
return final_prompt, image_ids, shot_detail
|
||||
|
||||
|
||||
def _provider_key_from_db_name(name: str) -> str:
|
||||
"""将 Provider.name 映射为任务层 ProviderKey(openai | volcengine)。"""
|
||||
n = (name or "").strip()
|
||||
n_lower = n.lower()
|
||||
if n_lower == "openai":
|
||||
return "openai"
|
||||
if n == "火山引擎" or "volc" in n_lower or "doubao" in n_lower or "bytedance" in n_lower:
|
||||
return "volcengine"
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Unsupported provider name: {name!r}. Expected: openai, 火山引擎.",
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_default_video_model(db: AsyncSession) -> Model:
|
||||
settings_row = await db.get(ModelSettings, 1)
|
||||
model_id = settings_row.default_video_model_id if settings_row else None
|
||||
if not model_id:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No default video model configured; please set ModelSettings.default_video_model_id first",
|
||||
)
|
||||
model = await db.get(Model, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=503, detail=f"Configured default video model not found: {model_id}")
|
||||
if model.category != ModelCategoryKey.video:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Configured default video model is not video category: {model_id} (category={model.category})",
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
async def _load_provider_config_by_model(db: AsyncSession, model: Model) -> ProviderConfig:
|
||||
provider = await db.get(Provider, model.provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Provider not found for model.provider_id={model.provider_id}",
|
||||
)
|
||||
provider_key = _provider_key_from_db_name(provider.name)
|
||||
api_key = (provider.api_key or "").strip()
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Provider api_key is empty for provider_id={provider.id}",
|
||||
)
|
||||
base_url = (provider.base_url or "").strip() or None
|
||||
return ProviderConfig(provider=provider_key, api_key=api_key, base_url=base_url) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def _persist_generated_video_to_shot(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_id: str,
|
||||
shot_id: str,
|
||||
result: VideoGenerationResult,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
) -> FileItem:
|
||||
"""将供应商返回的视频下载后写入对象存储,创建 FileItem,并回填 GenerationTaskLink 与 Shot。"""
|
||||
|
||||
url = (result.url or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("Video generation result has no download url")
|
||||
|
||||
url_headers: dict[str, str] | None = None
|
||||
if provider == "openai":
|
||||
url_headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
file_obj = await create_file_from_url_or_b64(
|
||||
session,
|
||||
url=url,
|
||||
name=f"shot-{shot_id}-video",
|
||||
prefix=f"generated-videos/shots/{shot_id}",
|
||||
url_request_headers=url_headers,
|
||||
httpx_timeout=600.0,
|
||||
)
|
||||
|
||||
link_stmt = (
|
||||
select(GenerationTaskLink)
|
||||
.where(
|
||||
GenerationTaskLink.task_id == task_id,
|
||||
GenerationTaskLink.resource_type == "video",
|
||||
GenerationTaskLink.relation_type == "video",
|
||||
GenerationTaskLink.relation_entity_id == shot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
link_row = (await session.execute(link_stmt)).scalars().first()
|
||||
if link_row is not None:
|
||||
link_row.file_id = file_obj.id
|
||||
|
||||
shot = await session.get(Shot, shot_id)
|
||||
if shot is not None:
|
||||
shot.generated_video_file_id = file_obj.id
|
||||
|
||||
await sync_usage_from_shot_context(
|
||||
session,
|
||||
file_id=file_obj.id,
|
||||
shot_id=shot_id,
|
||||
usage_kind=FileUsageKind.generated_video,
|
||||
source_ref=f"shot:{shot_id}:generated_video",
|
||||
)
|
||||
|
||||
return file_obj
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/video/preview-prompt",
|
||||
@@ -283,7 +35,7 @@ async def preview_video_generation_prompt(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[VideoPromptPreviewResponse]:
|
||||
"""预览视频生成的提示词与自动关联参考图。"""
|
||||
prompt, images, _shot_detail = await _preview_prompt_and_images_by_old_logic(
|
||||
prompt, images, _shot_detail = await preview_prompt_and_images(
|
||||
db,
|
||||
shot_id=body.shot_id,
|
||||
reference_mode=body.reference_mode,
|
||||
@@ -306,34 +58,14 @@ async def create_video_generation_task(
|
||||
|
||||
store = SqlAlchemyTaskStore(db)
|
||||
tm = TaskManager(store=store, strategies={})
|
||||
model = await _resolve_default_video_model(db)
|
||||
provider_cfg = await _load_provider_config_by_model(db, model)
|
||||
|
||||
shot_detail = await _validate_shot_and_duration(db, body.shot_id)
|
||||
_validate_images_count(body.reference_mode, body.images)
|
||||
final_prompt = (body.prompt or "").strip()
|
||||
if not final_prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
required_frames = REQUIRED_FRAMES_BY_MODE[body.reference_mode]
|
||||
frame_data_urls = [await _file_id_to_data_url(db, file_id=file_id) for file_id in body.images]
|
||||
frame_map = {ft: frame_data_urls[i] for i, ft in enumerate(required_frames)}
|
||||
|
||||
run_args: dict = {
|
||||
"shot_id": body.shot_id,
|
||||
"provider": provider_cfg.provider,
|
||||
"api_key": provider_cfg.api_key,
|
||||
"base_url": provider_cfg.base_url,
|
||||
"input": {
|
||||
"prompt": final_prompt,
|
||||
"first_frame_base64": frame_map.get(ShotFrameType.first),
|
||||
"last_frame_base64": frame_map.get(ShotFrameType.last),
|
||||
"key_frame_base64": frame_map.get(ShotFrameType.key),
|
||||
"model": model.name,
|
||||
"size": body.size,
|
||||
"seconds": shot_detail.duration,
|
||||
},
|
||||
}
|
||||
run_args = await build_run_args(
|
||||
db,
|
||||
shot_id=body.shot_id,
|
||||
reference_mode=body.reference_mode,
|
||||
prompt=body.prompt,
|
||||
images=body.images,
|
||||
size=body.size,
|
||||
)
|
||||
|
||||
task_record = await tm.create(
|
||||
task=_CreateOnlyTask(),
|
||||
@@ -352,67 +84,5 @@ async def create_video_generation_task(
|
||||
# 确保任务记录已提交,避免后台 runner 新 session 查询不到任务行而无法更新状态。
|
||||
await db.commit()
|
||||
|
||||
async def _runner(task_id: str, args: dict) -> None:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
store2 = SqlAlchemyTaskStore(session)
|
||||
await store2.set_status(task_id, TaskStatus.running)
|
||||
await store2.set_progress(task_id, 10)
|
||||
await session.commit()
|
||||
|
||||
provider = str(args.get("provider") or "")
|
||||
api_key = str(args.get("api_key") or "")
|
||||
base_url = args.get("base_url")
|
||||
input_dict = dict(args.get("input") or {})
|
||||
|
||||
task = VideoGenerationTask(
|
||||
provider_config=ProviderConfig(
|
||||
provider=provider, # type: ignore[arg-type]
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
),
|
||||
input_=VideoGenerationInput.model_validate(input_dict),
|
||||
)
|
||||
await task.run()
|
||||
result = await task.get_result()
|
||||
if result is None:
|
||||
# 同步透传底层任务中的具体错误,便于排查。
|
||||
status_dict = await task.status() # type: ignore[assignment]
|
||||
detailed_error = ""
|
||||
if isinstance(status_dict, dict):
|
||||
detailed_error = str(status_dict.get("error") or "")
|
||||
msg = detailed_error or "Video generation task returned no result"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
shot_id_run = str(args.get("shot_id") or "")
|
||||
if not shot_id_run:
|
||||
raise RuntimeError("run_args missing shot_id for video persistence")
|
||||
|
||||
file_obj = await _persist_generated_video_to_shot(
|
||||
session,
|
||||
task_id=task_id,
|
||||
shot_id=shot_id_run,
|
||||
result=result,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
result_payload = result.model_dump()
|
||||
result_payload["file_id"] = file_obj.id
|
||||
await store2.set_result(task_id, result_payload)
|
||||
await store2.set_progress(task_id, 100)
|
||||
await store2.set_status(task_id, TaskStatus.succeeded)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
async with async_session_maker() as s2:
|
||||
store3 = SqlAlchemyTaskStore(s2)
|
||||
await store3.set_error(task_id, str(exc))
|
||||
await store3.set_status(task_id, TaskStatus.failed)
|
||||
await s2.commit()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(_runner(task_record.id, run_args))
|
||||
return success_response(TaskCreated(task_id=task_record.id), code=201)
|
||||
|
||||
asyncio.create_task(run_video_generation_task(task_id=task_record.id, run_args=run_args))
|
||||
return created_response(TaskCreated(task_id=task_record.id))
|
||||
@@ -12,7 +12,8 @@ from app.core.task_manager.types import TaskStatus
|
||||
from app.dependencies import get_db
|
||||
from app.models.task import GenerationTask
|
||||
from app.models.task_links import GenerationTaskLink
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, paginated_response, success_response
|
||||
from app.services.common import entity_not_found
|
||||
|
||||
from .common import TaskLinkAdoptRead, TaskLinkAdoptRequest, TaskResultRead, TaskStatusRead, ensure_single_bind_target
|
||||
|
||||
@@ -72,7 +73,7 @@ async def get_task_status(
|
||||
store = SqlAlchemyTaskStore(db)
|
||||
view = await store.get_status_view(task_id)
|
||||
if view is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Task"))
|
||||
return success_response(TaskStatusRead(task_id=view.id, status=view.status, progress=view.progress))
|
||||
|
||||
|
||||
@@ -87,7 +88,7 @@ async def get_task_result(
|
||||
) -> ApiResponse[TaskResultRead]:
|
||||
row = await db.get(GenerationTask, task_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Task"))
|
||||
status_value = row.status.value if hasattr(row.status, "value") else str(row.status)
|
||||
return success_response(
|
||||
TaskResultRead(
|
||||
@@ -121,7 +122,7 @@ async def adopt_task_link(
|
||||
link = result.scalars().first()
|
||||
|
||||
if link is None:
|
||||
raise HTTPException(status_code=404, detail="Task link not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Task link"))
|
||||
|
||||
if str(link.status) == "accepted":
|
||||
raise HTTPException(
|
||||
@@ -210,7 +211,7 @@ async def create_task_link(
|
||||
db.add(link)
|
||||
await db.flush()
|
||||
await db.refresh(link)
|
||||
return success_response(GenerationTaskLinkRead.model_validate(link), code=201)
|
||||
return created_response(GenerationTaskLinkRead.model_validate(link))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -224,7 +225,7 @@ async def get_task_link(
|
||||
) -> ApiResponse[GenerationTaskLinkRead]:
|
||||
link = await db.get(GenerationTaskLink, link_id)
|
||||
if link is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task link not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Task link"))
|
||||
return success_response(GenerationTaskLinkRead.model_validate(link))
|
||||
|
||||
|
||||
@@ -240,7 +241,7 @@ async def update_task_link(
|
||||
) -> ApiResponse[GenerationTaskLinkRead]:
|
||||
link = await db.get(GenerationTaskLink, link_id)
|
||||
if link is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task link not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Task link"))
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(link, k, v)
|
||||
await db.flush()
|
||||
@@ -259,8 +260,7 @@ async def delete_task_link(
|
||||
) -> ApiResponse[None]:
|
||||
link = await db.get(GenerationTaskLink, link_id)
|
||||
if link is None:
|
||||
return success_response(None)
|
||||
return empty_response()
|
||||
await db.delete(link)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
|
||||
return empty_response()
|
||||
@@ -1,24 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
import asyncio
|
||||
|
||||
from app.chains.agents import (
|
||||
ShotFirstFramePromptAgent,
|
||||
ShotKeyFramePromptAgent,
|
||||
ShotLastFramePromptAgent,
|
||||
)
|
||||
from app.core.db import async_session_maker
|
||||
from fastapi import APIRouter, Depends
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.task_manager import DeliveryMode, SqlAlchemyTaskStore, TaskManager
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
from app.dependencies import get_db, get_nothinking_llm
|
||||
from app.models.studio import Shot, ShotDetail
|
||||
from app.models.studio import Chapter
|
||||
from app.models.task_links import GenerationTaskLink
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.schemas.common import ApiResponse, created_response
|
||||
from app.services.film.shot_frame_prompt_tasks import (
|
||||
build_run_args as build_shot_frame_prompt_run_args,
|
||||
normalize_frame_type,
|
||||
relation_type_for_frame,
|
||||
run_shot_frame_prompt_task,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
ShotFramePromptRequest,
|
||||
@@ -39,61 +35,16 @@ async def create_shot_frame_prompt_task(
|
||||
llm: BaseChatModel = Depends(get_nothinking_llm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[TaskCreated]:
|
||||
frame_type = (body.frame_type or "").strip().lower()
|
||||
if frame_type not in {"first", "last", "key"}:
|
||||
raise HTTPException(status_code=400, detail="frame_type must be one of first/last/key")
|
||||
if frame_type == "first":
|
||||
relation_type = "shot_first_frame_prompt"
|
||||
elif frame_type == "last":
|
||||
relation_type = "shot_last_frame_prompt"
|
||||
else:
|
||||
relation_type = "shot_key_frame_prompt"
|
||||
frame_type = normalize_frame_type(body.frame_type)
|
||||
relation_type = relation_type_for_frame(frame_type)
|
||||
|
||||
store = SqlAlchemyTaskStore(db)
|
||||
tm = TaskManager(store=store, strategies={})
|
||||
|
||||
shot_stmt = (
|
||||
select(Shot)
|
||||
.options(
|
||||
selectinload(Shot.detail).selectinload(ShotDetail.dialog_lines),
|
||||
selectinload(Shot.chapter).selectinload(Chapter.project),
|
||||
)
|
||||
.where(Shot.id == body.shot_id)
|
||||
run_args = await build_shot_frame_prompt_run_args(
|
||||
db,
|
||||
shot_id=body.shot_id,
|
||||
frame_type=frame_type,
|
||||
)
|
||||
shot = (await db.execute(shot_stmt)).scalar_one_or_none()
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
if shot.detail is None:
|
||||
raise HTTPException(status_code=404, detail="Shot detail not found")
|
||||
|
||||
detail = shot.detail
|
||||
dialog_summary = "\n".join(line.text for line in (detail.dialog_lines or []) if line.text)
|
||||
|
||||
# 把项目层的画面表现形式注入到提示词生成链路里。
|
||||
project = getattr(getattr(shot, "chapter", None), "project", None)
|
||||
visual_style = str(getattr(project, "visual_style", "") or "")
|
||||
style = str(getattr(project, "style", "") or "")
|
||||
input_dict = {
|
||||
"script_excerpt": shot.script_excerpt or "",
|
||||
"title": shot.title or "",
|
||||
"camera_shot": detail.camera_shot.value if hasattr(detail.camera_shot, "value") else str(detail.camera_shot),
|
||||
"angle": detail.angle.value if hasattr(detail.angle, "value") else str(detail.angle),
|
||||
"movement": detail.movement.value if hasattr(detail.movement, "value") else str(detail.movement),
|
||||
"atmosphere": detail.atmosphere or "",
|
||||
"mood_tags": detail.mood_tags or [],
|
||||
"vfx_type": detail.vfx_type.value if hasattr(detail.vfx_type, "value") else str(detail.vfx_type),
|
||||
"vfx_note": detail.vfx_note or "",
|
||||
"duration": detail.duration,
|
||||
"scene_id": detail.scene_id,
|
||||
"dialog_summary": dialog_summary,
|
||||
"visual_style": visual_style,
|
||||
"style": style,
|
||||
}
|
||||
run_args: dict = {
|
||||
"shot_id": body.shot_id,
|
||||
"frame_type": frame_type,
|
||||
"input": input_dict,
|
||||
}
|
||||
|
||||
task_record = await tm.create(task=_CreateOnlyTask(), mode=DeliveryMode.async_polling, run_args=run_args)
|
||||
db.add(
|
||||
@@ -105,55 +56,5 @@ async def create_shot_frame_prompt_task(
|
||||
)
|
||||
)
|
||||
|
||||
async def _runner(task_id: str, args: dict) -> None:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
store2 = SqlAlchemyTaskStore(session)
|
||||
await store2.set_status(task_id, TaskStatus.running)
|
||||
await store2.set_progress(task_id, 10)
|
||||
# 先提交 running 状态,避免长耗时执行期间任务一直显示 pending。
|
||||
await session.commit()
|
||||
|
||||
ft = str(args.get("frame_type") or "")
|
||||
shot_id = str(args.get("shot_id") or "")
|
||||
inp = dict(args.get("input") or {})
|
||||
|
||||
if ft == "first":
|
||||
agent = ShotFirstFramePromptAgent(llm)
|
||||
elif ft == "last":
|
||||
agent = ShotLastFramePromptAgent(llm)
|
||||
else:
|
||||
agent = ShotKeyFramePromptAgent(llm)
|
||||
result = await agent.aextract(**inp)
|
||||
|
||||
if not shot_id:
|
||||
raise RuntimeError("Missing shot_id in run args")
|
||||
shot_detail = await session.get(ShotDetail, shot_id)
|
||||
if shot_detail is None:
|
||||
raise RuntimeError("Shot detail not found when persisting prompt")
|
||||
|
||||
if ft == "first":
|
||||
shot_detail.first_frame_prompt = result.prompt
|
||||
elif ft == "last":
|
||||
shot_detail.last_frame_prompt = result.prompt
|
||||
else:
|
||||
shot_detail.key_frame_prompt = result.prompt
|
||||
|
||||
await store2.set_result(task_id, result.model_dump())
|
||||
await store2.set_progress(task_id, 100)
|
||||
await store2.set_status(task_id, TaskStatus.succeeded)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
async with async_session_maker() as s2:
|
||||
store3 = SqlAlchemyTaskStore(s2)
|
||||
await store3.set_error(task_id, str(exc))
|
||||
await store3.set_status(task_id, TaskStatus.failed)
|
||||
await s2.commit()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(_runner(task_record.id, run_args))
|
||||
return success_response(TaskCreated(task_id=task_record.id), code=201)
|
||||
|
||||
|
||||
asyncio.create_task(run_shot_frame_prompt_task(task_id=task_record.id, run_args=run_args, llm=llm))
|
||||
return created_response(TaskCreated(task_id=task_record.id))
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.schemas.common import ApiResponse, PaginatedData, success_response, paginated_response
|
||||
from app.models.llm import ModelCategoryKey
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, success_response
|
||||
from app.schemas.llm import (
|
||||
ModelCreate,
|
||||
ModelRead,
|
||||
@@ -21,6 +18,20 @@ from app.schemas.llm import (
|
||||
ProviderRead,
|
||||
ProviderUpdate,
|
||||
)
|
||||
from app.services.llm.manage import (
|
||||
create_model as create_model_service,
|
||||
create_provider as create_provider_service,
|
||||
delete_model as delete_model_service,
|
||||
delete_provider as delete_provider_service,
|
||||
get_model as get_model_service,
|
||||
get_model_settings as get_model_settings_service,
|
||||
get_provider as get_provider_service,
|
||||
list_models_paginated,
|
||||
list_providers_paginated,
|
||||
update_model as update_model_service,
|
||||
update_model_settings as update_model_settings_service,
|
||||
update_provider as update_provider_service,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -47,26 +58,15 @@ async def list_providers(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页条数"),
|
||||
) -> ApiResponse[PaginatedData[ProviderRead]]:
|
||||
stmt = select(Provider)
|
||||
count_stmt = select(func.count()).select_from(Provider)
|
||||
if q and q.strip():
|
||||
q_pattern = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
Provider.name.ilike(q_pattern) | Provider.description.ilike(q_pattern)
|
||||
)
|
||||
count_stmt = count_stmt.where(
|
||||
Provider.name.ilike(q_pattern) | Provider.description.ilike(q_pattern)
|
||||
)
|
||||
order_col = order if order and order in PROVIDER_ORDER_FIELDS else "created_at"
|
||||
order_attr = getattr(Provider, order_col)
|
||||
stmt = stmt.order_by(order_attr.desc() if is_desc else order_attr.asc())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
providers: Sequence[Provider] = result.scalars().all()
|
||||
items = [ProviderRead.model_validate(p) for p in providers]
|
||||
return paginated_response(items, page=page, page_size=page_size, total=total)
|
||||
return await list_providers_paginated(
|
||||
db,
|
||||
q=q,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
allow_fields=PROVIDER_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -79,26 +79,8 @@ async def create_provider(
|
||||
body: ProviderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProviderRead]:
|
||||
exists = await db.get(Provider, body.id)
|
||||
if exists is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Provider with id={body.id} already exists",
|
||||
)
|
||||
provider = Provider(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
base_url=body.base_url,
|
||||
api_key=body.api_key,
|
||||
api_secret=body.api_secret,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
created_by=body.created_by,
|
||||
)
|
||||
db.add(provider)
|
||||
await db.flush()
|
||||
await db.refresh(provider)
|
||||
return success_response(ProviderRead.model_validate(provider), code=201)
|
||||
provider = await create_provider_service(db, body=body)
|
||||
return created_response(ProviderRead.model_validate(provider))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -110,9 +92,7 @@ async def get_provider(
|
||||
provider_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProviderRead]:
|
||||
provider = await db.get(Provider, provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
provider = await get_provider_service(db, provider_id=provider_id)
|
||||
return success_response(ProviderRead.model_validate(provider))
|
||||
|
||||
|
||||
@@ -126,16 +106,7 @@ async def update_provider(
|
||||
body: ProviderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProviderRead]:
|
||||
provider = await db.get(Provider, provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(provider, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(provider)
|
||||
provider = await update_provider_service(db, provider_id=provider_id, body=body)
|
||||
return success_response(ProviderRead.model_validate(provider))
|
||||
|
||||
|
||||
@@ -149,12 +120,8 @@ async def delete_provider(
|
||||
provider_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
provider = await db.get(Provider, provider_id)
|
||||
if provider is None:
|
||||
return success_response(None)
|
||||
await db.delete(provider)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_provider_service(db, provider_id=provider_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
# ---------- Model ----------
|
||||
@@ -175,30 +142,17 @@ async def list_models(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页条数"),
|
||||
) -> ApiResponse[PaginatedData[ModelRead]]:
|
||||
stmt = select(Model)
|
||||
count_stmt = select(func.count()).select_from(Model)
|
||||
if provider_id is not None:
|
||||
stmt = stmt.where(Model.provider_id == provider_id)
|
||||
count_stmt = count_stmt.where(Model.provider_id == provider_id)
|
||||
if category is not None:
|
||||
stmt = stmt.where(Model.category == category)
|
||||
count_stmt = count_stmt.where(Model.category == category)
|
||||
if q and q.strip():
|
||||
q_pattern = f"%{q.strip()}%"
|
||||
stmt = stmt.where(Model.name.ilike(q_pattern) | Model.description.ilike(q_pattern))
|
||||
count_stmt = count_stmt.where(
|
||||
Model.name.ilike(q_pattern) | Model.description.ilike(q_pattern)
|
||||
)
|
||||
order_col = order if order and order in MODEL_ORDER_FIELDS else "created_at"
|
||||
order_attr = getattr(Model, order_col)
|
||||
stmt = stmt.order_by(order_attr.desc() if is_desc else order_attr.asc())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
models: Sequence[Model] = result.scalars().all()
|
||||
items = [ModelRead.model_validate(m) for m in models]
|
||||
return paginated_response(items, page=page, page_size=page_size, total=total)
|
||||
return await list_models_paginated(
|
||||
db,
|
||||
provider_id=provider_id,
|
||||
category=category,
|
||||
q=q,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
allow_fields=MODEL_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -211,39 +165,8 @@ async def create_model(
|
||||
body: ModelCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ModelRead]:
|
||||
exists = await db.get(Model, body.id)
|
||||
if exists is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Model with id={body.id} already exists",
|
||||
)
|
||||
|
||||
# 确保 provider 存在
|
||||
provider = await db.get(Provider, body.provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider not found")
|
||||
|
||||
# 若设为该类别默认,先取消同类别其他模型的默认
|
||||
if body.is_default:
|
||||
await db.execute(
|
||||
update(Model).where(Model.category == body.category).values(is_default=False)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
model = Model(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
category=body.category,
|
||||
provider_id=body.provider_id,
|
||||
params=body.params,
|
||||
description=body.description,
|
||||
is_default=body.is_default,
|
||||
created_by=body.created_by,
|
||||
)
|
||||
db.add(model)
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
return success_response(ModelRead.model_validate(model), code=201)
|
||||
model = await create_model_service(db, body=body)
|
||||
return created_response(ModelRead.model_validate(model))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -255,9 +178,7 @@ async def get_model(
|
||||
model_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ModelRead]:
|
||||
model = await db.get(Model, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
model = await get_model_service(db, model_id=model_id)
|
||||
return success_response(ModelRead.model_validate(model))
|
||||
|
||||
|
||||
@@ -271,31 +192,7 @@ async def update_model(
|
||||
body: ModelUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ModelRead]:
|
||||
model = await db.get(Model, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
# 如果更新 provider_id,需要校验新 provider 是否存在
|
||||
new_provider_id = update_data.get("provider_id")
|
||||
if new_provider_id is not None:
|
||||
provider = await db.get(Provider, new_provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider not found")
|
||||
|
||||
# 若设为该类别默认,先取消同类别其他模型的默认
|
||||
if update_data.get("is_default") is True:
|
||||
await db.execute(
|
||||
update(Model).where(Model.category == model.category).values(is_default=False)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(model, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(model)
|
||||
model = await update_model_service(db, model_id=model_id, body=body)
|
||||
return success_response(ModelRead.model_validate(model))
|
||||
|
||||
|
||||
@@ -309,28 +206,13 @@ async def delete_model(
|
||||
model_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
model = await db.get(Model, model_id)
|
||||
if model is None:
|
||||
return success_response(None)
|
||||
await db.delete(model)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_model_service(db, model_id=model_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
# ---------- ModelSettings(单例) ----------
|
||||
|
||||
|
||||
async def _get_or_create_settings(db: AsyncSession) -> ModelSettings:
|
||||
"""内部工具:获取或创建单例设置行(id=1)。"""
|
||||
settings = await db.get(ModelSettings, 1)
|
||||
if settings is None:
|
||||
settings = ModelSettings(id=1)
|
||||
db.add(settings)
|
||||
await db.flush()
|
||||
await db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
@router.get(
|
||||
"/model-settings",
|
||||
response_model=ApiResponse[ModelSettingsRead],
|
||||
@@ -339,7 +221,7 @@ async def _get_or_create_settings(db: AsyncSession) -> ModelSettings:
|
||||
async def get_model_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ModelSettingsRead]:
|
||||
settings = await _get_or_create_settings(db)
|
||||
settings = await get_model_settings_service(db)
|
||||
return success_response(ModelSettingsRead.model_validate(settings))
|
||||
|
||||
|
||||
@@ -352,13 +234,5 @@ async def update_model_settings(
|
||||
body: ModelSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ModelSettingsRead]:
|
||||
settings = await _get_or_create_settings(db)
|
||||
|
||||
update_data = body.model_dump()
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(settings)
|
||||
settings = await update_model_settings_service(db, body=body)
|
||||
return success_response(ModelSettingsRead.model_validate(settings))
|
||||
|
||||
@@ -4,13 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.chains.agents import (
|
||||
@@ -36,12 +34,13 @@ from app.chains.agents.script_processing_agents import (
|
||||
StudioScriptExtractionDraft,
|
||||
)
|
||||
from app.dependencies import get_db, get_llm, get_nothinking_llm
|
||||
from app.models.studio import CameraAngle, CameraMovement, CameraShotType, Chapter, Shot, ShotDetail, VFXType
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.schemas.skills.character_portrait import CharacterPortraitAnalysisResult
|
||||
from app.schemas.skills.costume_info_analysis import CostumeInfoAnalysisResult
|
||||
from app.schemas.skills.prop_info_analysis import PropInfoAnalysisResult
|
||||
from app.schemas.skills.scene_info_analysis import SceneInfoAnalysisResult
|
||||
from app.services.common import required_field
|
||||
from app.services.studio.script_division import write_division_result_to_chapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,46 +94,12 @@ async def divide_script(
|
||||
|
||||
if request.write_to_db:
|
||||
if not request.chapter_id:
|
||||
raise HTTPException(status_code=400, detail="chapter_id is required when write_to_db=true")
|
||||
|
||||
chapter = await db.get(Chapter, request.chapter_id)
|
||||
if chapter is None:
|
||||
raise HTTPException(status_code=400, detail="Chapter not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(Shot.id).where(Shot.chapter_id == request.chapter_id).limit(1)
|
||||
raise HTTPException(status_code=400, detail=required_field("chapter_id", when="write_to_db=true"))
|
||||
await write_division_result_to_chapter(
|
||||
db,
|
||||
chapter_id=request.chapter_id,
|
||||
result=result,
|
||||
)
|
||||
if existing.first() is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Chapter already has shots; refusing to write (write_strategy=fail)",
|
||||
)
|
||||
|
||||
for s in result.shots:
|
||||
title = (s.shot_name or "").strip() or f"镜头 {s.index}"
|
||||
shot_id = str(uuid.uuid4())
|
||||
db.add(
|
||||
Shot(
|
||||
id=shot_id,
|
||||
chapter_id=request.chapter_id,
|
||||
index=s.index,
|
||||
title=title,
|
||||
script_excerpt=s.script_excerpt,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
ShotDetail(
|
||||
id=shot_id,
|
||||
camera_shot=CameraShotType.ms,
|
||||
angle=CameraAngle.eye_level,
|
||||
movement=CameraMovement.static,
|
||||
follow_atmosphere=True,
|
||||
vfx_type=VFXType.none,
|
||||
duration=4,
|
||||
)
|
||||
)
|
||||
# 触发唯一约束/外键检查,确保在返回前失败
|
||||
await db.flush()
|
||||
|
||||
return success_response(data=result)
|
||||
except HTTPException:
|
||||
@@ -659,4 +624,3 @@ async def full_process(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to process script: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.dependencies import get_db
|
||||
from app.models.studio import Chapter, Project, Shot
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, paginated_response, success_response
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_already_exists,
|
||||
entity_not_found,
|
||||
ensure_not_exists,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
require_entity,
|
||||
)
|
||||
from app.schemas.studio.projects import ChapterCreate, ChapterRead, ChapterUpdate
|
||||
|
||||
router = APIRouter()
|
||||
@@ -77,17 +88,21 @@ async def create_chapter(
|
||||
body: ChapterCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ChapterRead]:
|
||||
exists = await db.get(Chapter, body.id)
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail=f"Chapter with id={body.id} already exists")
|
||||
project = await db.get(Project, body.project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=400, detail="Project not found")
|
||||
obj = Chapter(**body.model_dump())
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(ChapterRead.model_validate(obj), code=201)
|
||||
await ensure_not_exists(
|
||||
db,
|
||||
Chapter,
|
||||
body.id,
|
||||
detail=entity_already_exists("Chapter"),
|
||||
)
|
||||
await require_entity(
|
||||
db,
|
||||
Project,
|
||||
body.project_id,
|
||||
detail=entity_not_found("Project"),
|
||||
status_code=400,
|
||||
)
|
||||
obj = await create_and_refresh(db, Chapter(**body.model_dump()))
|
||||
return created_response(ChapterRead.model_validate(obj))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -99,9 +114,7 @@ async def get_chapter(
|
||||
chapter_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ChapterRead]:
|
||||
obj = await db.get(Chapter, chapter_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="Chapter not found")
|
||||
obj = await get_or_404(db, Chapter, chapter_id, detail=entity_not_found("Chapter"))
|
||||
count_stmt = select(func.count(Shot.id)).where(Shot.chapter_id == chapter_id)
|
||||
res = await db.execute(count_stmt)
|
||||
shot_count = int(res.scalar() or 0)
|
||||
@@ -118,18 +131,18 @@ async def update_chapter(
|
||||
body: ChapterUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ChapterRead]:
|
||||
obj = await db.get(Chapter, chapter_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="Chapter not found")
|
||||
obj = await get_or_404(db, Chapter, chapter_id, detail=entity_not_found("Chapter"))
|
||||
update = body.model_dump(exclude_unset=True)
|
||||
if "project_id" in update:
|
||||
project = await db.get(Project, update["project_id"])
|
||||
if project is None:
|
||||
raise HTTPException(status_code=400, detail="Project not found")
|
||||
for k, v in update.items():
|
||||
setattr(obj, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
await require_entity(
|
||||
db,
|
||||
Project,
|
||||
update["project_id"],
|
||||
detail=entity_not_found("Project"),
|
||||
status_code=400,
|
||||
)
|
||||
patch_model(obj, update)
|
||||
await flush_and_refresh(db, obj)
|
||||
return success_response(ChapterRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -142,10 +155,5 @@ async def delete_chapter(
|
||||
chapter_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(Chapter, chapter_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
|
||||
await delete_if_exists(db, Chapter, chapter_id)
|
||||
return empty_response()
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, paginated_response, success_response
|
||||
from app.schemas.studio.entity_existence import (
|
||||
EntityNameExistenceCheckRequest,
|
||||
EntityNameExistenceCheckResponse,
|
||||
@@ -73,7 +73,7 @@ async def create_entity(
|
||||
) -> ApiResponse[dict[str, Any]]:
|
||||
service = StudioEntitiesService(db)
|
||||
payload = await service.create_entity(entity_type=entity_type, body=body)
|
||||
return success_response(payload, code=201)
|
||||
return created_response(payload)
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}", response_model=ApiResponse[dict[str, Any]], summary="统一获取实体")
|
||||
@@ -99,7 +99,7 @@ async def update_entity(
|
||||
async def delete_entity(entity_type: str, entity_id: str, db: AsyncSession = Depends(get_db)) -> ApiResponse[None]:
|
||||
service = StudioEntitiesService(db)
|
||||
await service.delete_entity(entity_type=entity_type, entity_id=entity_id)
|
||||
return success_response(None)
|
||||
return empty_response()
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -142,7 +142,7 @@ async def create_entity_image(
|
||||
) -> ApiResponse[dict[str, Any]]:
|
||||
service = StudioEntitiesService(db)
|
||||
payload = await service.create_entity_image(entity_type=entity_type, entity_id=entity_id, body=body)
|
||||
return success_response(payload, code=201)
|
||||
return created_response(payload)
|
||||
|
||||
|
||||
@router.patch(
|
||||
@@ -180,5 +180,4 @@ async def delete_entity_image(
|
||||
) -> ApiResponse[None]:
|
||||
service = StudioEntitiesService(db)
|
||||
await service.delete_entity_image(entity_type=entity_type, entity_id=entity_id, image_id=image_id)
|
||||
return success_response(None)
|
||||
|
||||
return empty_response()
|
||||
@@ -2,29 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.core import storage
|
||||
from app.dependencies import get_db
|
||||
from app.models.studio import FileItem, FileType
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.schemas.studio import FileDetailRead, FileRead, FileUpdate, FileUsageRead, FileUsageWrite
|
||||
from app.services.studio.file_usages import list_files_by_scope_paginated, upsert_file_usage
|
||||
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, paginated_response, success_response
|
||||
from app.schemas.studio import FileDetailRead, FileRead, FileUpdate
|
||||
from app.services.studio.file_usages import list_files_by_scope_paginated
|
||||
from app.services.studio.files import (
|
||||
build_download_response,
|
||||
delete_file,
|
||||
get_file_detail as get_file_detail_service,
|
||||
get_storage_info,
|
||||
list_files_paginated,
|
||||
update_file_meta as update_file_meta_service,
|
||||
upload_file,
|
||||
)
|
||||
router = APIRouter()
|
||||
|
||||
FILE_ORDER_FIELDS = {"name", "created_at", "updated_at"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
@@ -67,23 +62,13 @@ async def list_files_api(
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
stmt = select(FileItem)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[FileItem.name])
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=FileItem,
|
||||
return await list_files_paginated(
|
||||
db,
|
||||
q=q,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=FILE_ORDER_FIELDS,
|
||||
default="created_at",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[FileRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@@ -103,73 +88,17 @@ async def upload_file_api(
|
||||
usage_kind: str | None = Form(None, description="与 project_id 同时提供时写入 file_usages"),
|
||||
source_ref: str | None = Form(None),
|
||||
) -> ApiResponse[FileRead]:
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="上传文件缺少文件名")
|
||||
|
||||
# 从后缀推断文件类型
|
||||
_, ext = os.path.splitext(file.filename.lower())
|
||||
if ext in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
|
||||
file_type = FileType.image
|
||||
elif ext in {".mp4", ".mov", ".mkv", ".avi", ".webm"}:
|
||||
file_type = FileType.video
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {ext or '未知后缀'}")
|
||||
|
||||
# 名称:未传 name 时使用“去掉后缀的文件名”
|
||||
if name:
|
||||
display_name = name
|
||||
else:
|
||||
base, _ = os.path.splitext(file.filename)
|
||||
display_name = base or file.filename
|
||||
|
||||
content = await file.read()
|
||||
|
||||
# 这里存“逻辑 key”,不再包含 project_id/chapter_id,归属由 file_usages 区分。
|
||||
key = f"files/{file.filename}"
|
||||
info = await storage.upload_file(
|
||||
key=key,
|
||||
data=content,
|
||||
content_type=file.content_type,
|
||||
extra_args={"ACL": "public-read"},
|
||||
obj = await upload_file(
|
||||
db,
|
||||
file=file,
|
||||
name=name,
|
||||
project_id=project_id,
|
||||
chapter_id=chapter_id,
|
||||
shot_id=shot_id,
|
||||
usage_kind=usage_kind,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
|
||||
# 使用 UUID 作为对外 file_id,storage_key 单独存储
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
obj = await db.get(FileItem, file_id)
|
||||
if obj is None:
|
||||
obj = FileItem(
|
||||
id=file_id,
|
||||
type=file_type,
|
||||
name=display_name,
|
||||
thumbnail=info.url,
|
||||
tags=[],
|
||||
storage_key=key,
|
||||
)
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
else:
|
||||
obj.type = file_type
|
||||
obj.name = display_name
|
||||
if not obj.thumbnail:
|
||||
obj.thumbnail = info.url
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
|
||||
if project_id and usage_kind:
|
||||
await upsert_file_usage(
|
||||
db,
|
||||
file_id=obj.id,
|
||||
project_id=project_id,
|
||||
chapter_id=chapter_id,
|
||||
shot_id=shot_id,
|
||||
usage_kind=usage_kind,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
|
||||
return success_response(FileRead.model_validate(obj), code=201)
|
||||
return created_response(FileRead.model_validate(obj))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -180,42 +109,7 @@ async def download_file_api(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
obj = await db.get(FileItem, file_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
content = await storage.download_file(key=obj.storage_key)
|
||||
|
||||
# 使用存储 key 的原始文件名(包含扩展名)作为下载文件名
|
||||
filename = Path(obj.storage_key).name or "download"
|
||||
|
||||
# 根据扩展名设置合理的 Content-Type,兼容常见图片/视频类型
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext in {".jpg", ".jpeg"}:
|
||||
media_type = "image/jpeg"
|
||||
elif ext == ".png":
|
||||
media_type = "image/png"
|
||||
elif ext == ".webp":
|
||||
media_type = "image/webp"
|
||||
elif ext == ".gif":
|
||||
media_type = "image/gif"
|
||||
elif ext == ".mp4":
|
||||
media_type = "video/mp4"
|
||||
elif ext == ".mov":
|
||||
media_type = "video/quicktime"
|
||||
elif ext in {".mkv", ".avi", ".webm"}:
|
||||
media_type = "video/" + ext.lstrip(".")
|
||||
else:
|
||||
media_type = "application/octet-stream"
|
||||
|
||||
# Content-Disposition 中的文件名需要是 ASCII;使用 RFC 5987 形式避免中文导致的编码错误。
|
||||
safe_filename = filename
|
||||
content_disposition = f"attachment; filename*=UTF-8''{quote(safe_filename)}"
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": content_disposition},
|
||||
)
|
||||
return await build_download_response(db, file_id=file_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -227,20 +121,7 @@ async def get_file_storage_info_api(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[dict]:
|
||||
obj = await db.get(FileItem, file_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
info = await storage.get_file_info(key=obj.storage_key)
|
||||
return success_response(
|
||||
{
|
||||
"key": info.key,
|
||||
"url": info.url,
|
||||
"size": info.size,
|
||||
"content_type": info.content_type,
|
||||
"etag": info.etag,
|
||||
}
|
||||
)
|
||||
return success_response(await get_storage_info(db, file_id=file_id))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -252,14 +133,7 @@ async def get_file_detail(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[FileDetailRead]:
|
||||
stmt = select(FileItem).options(selectinload(FileItem.usages)).where(FileItem.id == file_id)
|
||||
res = await db.execute(stmt)
|
||||
obj = res.scalars().first()
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
usages = [FileUsageRead.model_validate(u) for u in (obj.usages or [])]
|
||||
base = FileRead.model_validate(obj)
|
||||
return success_response(FileDetailRead(**base.model_dump(), usages=usages))
|
||||
return success_response(await get_file_detail_service(db, file_id=file_id))
|
||||
|
||||
|
||||
@router.patch(
|
||||
@@ -272,27 +146,7 @@ async def update_file_meta(
|
||||
body: FileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[FileRead]:
|
||||
obj = await db.get(FileItem, file_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
usage_payload = data.pop("usage", None)
|
||||
for k, v in data.items():
|
||||
setattr(obj, k, v)
|
||||
if usage_payload is not None:
|
||||
u = FileUsageWrite.model_validate(usage_payload)
|
||||
await upsert_file_usage(
|
||||
db,
|
||||
file_id=file_id,
|
||||
project_id=u.project_id,
|
||||
chapter_id=u.chapter_id,
|
||||
shot_id=u.shot_id,
|
||||
usage_kind=u.usage_kind,
|
||||
source_ref=u.source_ref,
|
||||
)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
obj = await update_file_meta_service(db, file_id=file_id, body=body)
|
||||
return success_response(FileRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -305,15 +159,5 @@ async def delete_file_api(
|
||||
file_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(FileItem, file_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
|
||||
try:
|
||||
await storage.delete_file(key=obj.storage_key)
|
||||
except Exception:
|
||||
...
|
||||
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_file(db, file_id=file_id)
|
||||
return empty_response()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.dependencies import get_db
|
||||
from app.models.studio import Project
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, paginated_response, success_response
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_already_exists,
|
||||
entity_not_found,
|
||||
ensure_not_exists,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
)
|
||||
from app.schemas.studio.projects import (
|
||||
ProjectCreate,
|
||||
ProjectRead,
|
||||
@@ -51,14 +61,14 @@ async def create_project(
|
||||
body: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectRead]:
|
||||
exists = await db.get(Project, body.id)
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail=f"Project with id={body.id} already exists")
|
||||
obj = Project(**body.model_dump())
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(ProjectRead.model_validate(obj), code=201)
|
||||
await ensure_not_exists(
|
||||
db,
|
||||
Project,
|
||||
body.id,
|
||||
detail=entity_already_exists("Project"),
|
||||
)
|
||||
obj = await create_and_refresh(db, Project(**body.model_dump()))
|
||||
return created_response(ProjectRead.model_validate(obj))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -70,9 +80,7 @@ async def get_project(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectRead]:
|
||||
obj = await db.get(Project, project_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
obj = await get_or_404(db, Project, project_id, detail=entity_not_found("Project"))
|
||||
return success_response(ProjectRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -86,13 +94,9 @@ async def update_project(
|
||||
body: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectRead]:
|
||||
obj = await db.get(Project, project_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
obj = await get_or_404(db, Project, project_id, detail=entity_not_found("Project"))
|
||||
patch_model(obj, body.model_dump(exclude_unset=True))
|
||||
await flush_and_refresh(db, obj)
|
||||
return success_response(ProjectRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -105,10 +109,5 @@ async def delete_project(
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(Project, project_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
|
||||
await delete_if_exists(db, Project, project_id)
|
||||
return empty_response()
|
||||
@@ -15,18 +15,18 @@ from typing import cast
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.dependencies import get_db
|
||||
from app.models.studio import PromptCategory, PromptTemplate
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, paginated_response, success_response
|
||||
from app.schemas.studio.prompts import (
|
||||
PromptCategoryOptionRead,
|
||||
PromptTemplateCreate,
|
||||
PromptTemplateRead,
|
||||
PromptTemplateUpdate,
|
||||
)
|
||||
from app.services.common import entity_not_found
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -144,7 +144,7 @@ async def get_prompt_template(
|
||||
) -> ApiResponse[PromptTemplateRead]:
|
||||
obj = await db.get(PromptTemplate, template_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="PromptTemplate not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("PromptTemplate"))
|
||||
return success_response(PromptTemplateRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ async def create_prompt_template(
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(PromptTemplateRead.model_validate(obj), code=201)
|
||||
return created_response(PromptTemplateRead.model_validate(obj))
|
||||
|
||||
|
||||
# ---------- 更新 ----------
|
||||
@@ -194,7 +194,7 @@ async def update_prompt_template(
|
||||
) -> ApiResponse[PromptTemplateRead]:
|
||||
obj = await db.get(PromptTemplate, template_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="PromptTemplate not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("PromptTemplate"))
|
||||
if obj.is_system:
|
||||
raise HTTPException(status_code=403, detail="系统预置提示词不可修改")
|
||||
|
||||
@@ -215,21 +215,19 @@ async def update_prompt_template(
|
||||
|
||||
@router.delete(
|
||||
"/{template_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
response_model=ApiResponse[None],
|
||||
summary="删除提示词模板",
|
||||
)
|
||||
async def delete_prompt_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(PromptTemplate, template_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="PromptTemplate not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("PromptTemplate"))
|
||||
if obj.is_system:
|
||||
raise HTTPException(status_code=403, detail="系统预置提示词不可删除")
|
||||
if obj.is_default:
|
||||
raise HTTPException(status_code=403, detail="默认提示词不可删除,请先将其他提示词设为默认")
|
||||
await db.delete(obj)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
return empty_response()
|
||||
@@ -3,13 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.models.studio import Chapter, Character, Shot, ShotCharacterLink
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.schemas.studio.cast import ShotCharacterLinkCreate, ShotCharacterLinkRead
|
||||
from app.services.studio.shot_character_links import (
|
||||
list_by_shot,
|
||||
upsert,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -23,15 +25,7 @@ async def list_shot_character_links(
|
||||
shot_id: str = Query(..., description="镜头 ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[list[ShotCharacterLinkRead]]:
|
||||
if await db.get(Shot, shot_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
|
||||
stmt = (
|
||||
select(ShotCharacterLink)
|
||||
.where(ShotCharacterLink.shot_id == shot_id)
|
||||
.order_by(ShotCharacterLink.index.asc(), ShotCharacterLink.id.asc())
|
||||
)
|
||||
items = (await db.execute(stmt)).scalars().all()
|
||||
items = await list_by_shot(db, shot_id=shot_id)
|
||||
return success_response([ShotCharacterLinkRead.model_validate(x) for x in items])
|
||||
|
||||
|
||||
@@ -44,51 +38,8 @@ async def upsert_shot_character_link(
|
||||
body: ShotCharacterLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotCharacterLinkRead]:
|
||||
shot = await db.get(Shot, body.shot_id)
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
|
||||
chapter = await db.get(Chapter, shot.chapter_id)
|
||||
if chapter is None:
|
||||
raise HTTPException(status_code=400, detail="Chapter not found for shot")
|
||||
|
||||
character = await db.get(Character, body.character_id)
|
||||
if character is None:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
|
||||
if character.project_id != chapter.project_id:
|
||||
raise HTTPException(status_code=400, detail="Character does not belong to the same project")
|
||||
|
||||
existing_same_character_stmt = select(ShotCharacterLink).where(
|
||||
ShotCharacterLink.shot_id == body.shot_id,
|
||||
ShotCharacterLink.character_id == body.character_id,
|
||||
)
|
||||
existing = (await db.execute(existing_same_character_stmt)).scalars().one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
existing.index = body.index
|
||||
existing.note = body.note
|
||||
await db.flush()
|
||||
await db.refresh(existing)
|
||||
return success_response(ShotCharacterLinkRead.model_validate(existing))
|
||||
|
||||
# 避免 shot_id + index 唯一冲突:如果 index 已被其他角色占用,则删除旧的记录
|
||||
existing_same_index_stmt = select(ShotCharacterLink).where(
|
||||
ShotCharacterLink.shot_id == body.shot_id,
|
||||
ShotCharacterLink.index == body.index,
|
||||
)
|
||||
existing_same_index = (await db.execute(existing_same_index_stmt)).scalars().one_or_none()
|
||||
if existing_same_index is not None:
|
||||
await db.execute(delete(ShotCharacterLink).where(ShotCharacterLink.id == existing_same_index.id))
|
||||
|
||||
obj = ShotCharacterLink(
|
||||
shot_id=body.shot_id,
|
||||
character_id=body.character_id,
|
||||
index=body.index,
|
||||
note=body.note,
|
||||
)
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
try:
|
||||
obj = await upsert(db, body=body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return success_response(ShotCharacterLinkRead.model_validate(obj))
|
||||
|
||||
@@ -4,37 +4,43 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.dependencies import get_db
|
||||
from app.services.studio.entities import (
|
||||
entity_spec,
|
||||
normalize_entity_type,
|
||||
resolve_thumbnail_infos,
|
||||
resolve_thumbnails,
|
||||
from app.services.studio.shot_assets import (
|
||||
create_project_asset_link as create_project_asset_link_service,
|
||||
delete_project_asset_link as delete_project_asset_link_service,
|
||||
list_project_asset_links_paginated,
|
||||
list_shot_linked_assets_paginated,
|
||||
)
|
||||
from app.models.studio import (
|
||||
Chapter,
|
||||
Character,
|
||||
Costume,
|
||||
Project,
|
||||
Prop,
|
||||
Scene,
|
||||
Shot,
|
||||
Actor,
|
||||
ProjectActorLink,
|
||||
ProjectCostumeLink,
|
||||
ProjectPropLink,
|
||||
ProjectSceneLink,
|
||||
ShotDetail,
|
||||
ShotDialogLine,
|
||||
ShotCharacterLink,
|
||||
ShotFrameImage,
|
||||
from app.services.studio.shot_details import (
|
||||
create as create_shot_detail_service,
|
||||
get as get_shot_detail_service,
|
||||
list_paginated as list_shot_details_paginated,
|
||||
update as update_shot_detail_service,
|
||||
delete as delete_shot_detail_service,
|
||||
)
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response, success_response
|
||||
from app.services.studio.shot_dialogs import (
|
||||
create as create_shot_dialog_line_service,
|
||||
list_paginated as list_shot_dialog_lines_paginated,
|
||||
update as update_shot_dialog_line_service,
|
||||
delete as delete_shot_dialog_line_service,
|
||||
)
|
||||
from app.services.studio.shot_frames import (
|
||||
create as create_shot_frame_image_service,
|
||||
delete as delete_shot_frame_image_service,
|
||||
list_paginated as list_shot_frame_images_paginated,
|
||||
update as update_shot_frame_image_service,
|
||||
)
|
||||
from app.services.studio.shots import (
|
||||
create as create_shot_service,
|
||||
delete as delete_shot_service,
|
||||
get as get_shot_service,
|
||||
list_paginated as list_shots_paginated,
|
||||
update as update_shot_service,
|
||||
)
|
||||
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, success_response
|
||||
from app.schemas.skills.script_processing import StudioScriptExtractionDraft
|
||||
from app.services.studio.shot_extraction_draft import build_script_extraction_draft_for_shot
|
||||
from app.schemas.studio.shots import (
|
||||
@@ -57,7 +63,6 @@ from app.schemas.studio.shots import (
|
||||
ShotFrameImageRead,
|
||||
ShotFrameImageUpdate,
|
||||
)
|
||||
from app.utils.project_links import upsert_project_link
|
||||
|
||||
router = APIRouter()
|
||||
details_router = APIRouter()
|
||||
@@ -72,62 +77,6 @@ LINK_ORDER_FIELDS = {"id", "created_at", "updated_at"}
|
||||
FRAME_IMAGE_ORDER_FIELDS = {"id", "frame_type", "created_at", "updated_at"}
|
||||
|
||||
|
||||
async def _ensure_chapter(db: AsyncSession, chapter_id: str) -> None:
|
||||
if await db.get(Chapter, chapter_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Chapter not found")
|
||||
|
||||
|
||||
async def _ensure_project(db: AsyncSession, project_id: str) -> None:
|
||||
if await db.get(Project, project_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Project not found")
|
||||
|
||||
|
||||
async def _ensure_chapter_optional(db: AsyncSession, chapter_id: str | None) -> None:
|
||||
if chapter_id is None:
|
||||
return
|
||||
await _ensure_chapter(db, chapter_id)
|
||||
|
||||
|
||||
async def _ensure_shot_optional(db: AsyncSession, shot_id: str | None) -> None:
|
||||
if shot_id is None:
|
||||
return
|
||||
await _ensure_shot(db, shot_id)
|
||||
|
||||
|
||||
async def _ensure_shot(db: AsyncSession, shot_id: str) -> None:
|
||||
if await db.get(Shot, shot_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Shot not found")
|
||||
|
||||
|
||||
async def _ensure_scene_optional(db: AsyncSession, scene_id: str | None) -> None:
|
||||
if scene_id is None:
|
||||
return
|
||||
if await db.get(Scene, scene_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Scene not found")
|
||||
|
||||
|
||||
async def _ensure_character_optional(db: AsyncSession, character_id: str | None) -> None:
|
||||
if character_id is None:
|
||||
return
|
||||
if await db.get(Character, character_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Character not found")
|
||||
|
||||
|
||||
async def _ensure_actor(db: AsyncSession, actor_id: str) -> None:
|
||||
if await db.get(Actor, actor_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Actor not found")
|
||||
|
||||
|
||||
async def _ensure_prop(db: AsyncSession, prop_id: str) -> None:
|
||||
if await db.get(Prop, prop_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Prop not found")
|
||||
|
||||
|
||||
async def _ensure_costume(db: AsyncSession, costume_id: str) -> None:
|
||||
if await db.get(Costume, costume_id) is None:
|
||||
raise HTTPException(status_code=400, detail="Costume not found")
|
||||
|
||||
|
||||
# ---------- Shot ----------
|
||||
|
||||
|
||||
@@ -145,13 +94,16 @@ async def list_shots(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
) -> ApiResponse[PaginatedData[ShotRead]]:
|
||||
stmt = select(Shot)
|
||||
if chapter_id is not None:
|
||||
stmt = stmt.where(Shot.chapter_id == chapter_id)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[Shot.title, Shot.script_excerpt])
|
||||
stmt = apply_order(stmt, model=Shot, order=order, is_desc=is_desc, allow_fields=SHOT_ORDER_FIELDS, default="index")
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response([ShotRead.model_validate(x) for x in items], page=page, page_size=page_size, total=total)
|
||||
return await list_shots_paginated(
|
||||
db,
|
||||
chapter_id=chapter_id,
|
||||
q=q,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
allow_fields=SHOT_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -164,15 +116,8 @@ async def create_shot(
|
||||
body: ShotCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotRead]:
|
||||
exists = await db.get(Shot, body.id)
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail=f"Shot with id={body.id} already exists")
|
||||
await _ensure_chapter(db, body.chapter_id)
|
||||
obj = Shot(**body.model_dump())
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(ShotRead.model_validate(obj), code=201)
|
||||
obj = await create_shot_service(db, body=body)
|
||||
return created_response(ShotRead.model_validate(obj))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -197,9 +142,7 @@ async def get_shot(
|
||||
shot_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotRead]:
|
||||
obj = await db.get(Shot, shot_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
obj = await get_shot_service(db, shot_id=shot_id)
|
||||
return success_response(ShotRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -213,16 +156,7 @@ async def update_shot(
|
||||
body: ShotUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotRead]:
|
||||
obj = await db.get(Shot, shot_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "chapter_id" in update_data:
|
||||
await _ensure_chapter(db, update_data["chapter_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(obj, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
obj = await update_shot_service(db, shot_id=shot_id, body=body)
|
||||
return success_response(ShotRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -235,12 +169,8 @@ async def delete_shot(
|
||||
shot_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(Shot, shot_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_shot_service(db, shot_id=shot_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -254,137 +184,12 @@ async def list_shot_linked_assets(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
) -> ApiResponse[PaginatedData[ShotLinkedAssetItem]]:
|
||||
await _ensure_shot(db, shot_id)
|
||||
|
||||
# 1) Collect linked entity ids (links-only as requested)
|
||||
character_ids = (await db.execute(
|
||||
select(ShotCharacterLink.character_id).where(ShotCharacterLink.shot_id == shot_id)
|
||||
)).scalars().all()
|
||||
prop_ids = (await db.execute(
|
||||
select(ProjectPropLink.prop_id).where(ProjectPropLink.shot_id == shot_id)
|
||||
)).scalars().all()
|
||||
scene_ids = (await db.execute(
|
||||
select(ProjectSceneLink.scene_id).where(ProjectSceneLink.shot_id == shot_id)
|
||||
)).scalars().all()
|
||||
costume_ids = (await db.execute(
|
||||
select(ProjectCostumeLink.costume_id).where(ProjectCostumeLink.shot_id == shot_id)
|
||||
)).scalars().all()
|
||||
|
||||
# de-dup & drop nulls
|
||||
character_ids = [x for x in dict.fromkeys(character_ids) if x]
|
||||
prop_ids = [x for x in dict.fromkeys(prop_ids) if x]
|
||||
scene_ids = [x for x in dict.fromkeys(scene_ids) if x]
|
||||
costume_ids = [x for x in dict.fromkeys(costume_ids) if x]
|
||||
|
||||
# 2) Load names
|
||||
character_rows = []
|
||||
if character_ids:
|
||||
character_rows = (await db.execute(
|
||||
select(Character.id, Character.name).where(Character.id.in_(character_ids))
|
||||
)).all()
|
||||
prop_rows = []
|
||||
if prop_ids:
|
||||
prop_rows = (await db.execute(
|
||||
select(Prop.id, Prop.name).where(Prop.id.in_(prop_ids))
|
||||
)).all()
|
||||
scene_rows = []
|
||||
if scene_ids:
|
||||
scene_rows = (await db.execute(
|
||||
select(Scene.id, Scene.name).where(Scene.id.in_(scene_ids))
|
||||
)).all()
|
||||
costume_rows = []
|
||||
if costume_ids:
|
||||
costume_rows = (await db.execute(
|
||||
select(Costume.id, Costume.name).where(Costume.id.in_(costume_ids))
|
||||
)).all()
|
||||
|
||||
character_name = {str(r[0]): str(r[1]) for r in character_rows}
|
||||
prop_name = {str(r[0]): str(r[1]) for r in prop_rows}
|
||||
scene_name = {str(r[0]): str(r[1]) for r in scene_rows}
|
||||
costume_name = {str(r[0]): str(r[1]) for r in costume_rows}
|
||||
|
||||
# 3) Resolve thumbnail + image_id
|
||||
character_thumb = await resolve_thumbnail_infos(
|
||||
return await list_shot_linked_assets_paginated(
|
||||
db,
|
||||
image_model=entity_spec("character").image_model,
|
||||
parent_field_name="character_id",
|
||||
parent_ids=list(character_name.keys()),
|
||||
shot_id=shot_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
prop_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("prop").image_model,
|
||||
parent_field_name="prop_id",
|
||||
parent_ids=list(prop_name.keys()),
|
||||
)
|
||||
scene_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("scene").image_model,
|
||||
parent_field_name="scene_id",
|
||||
parent_ids=list(scene_name.keys()),
|
||||
)
|
||||
costume_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("costume").image_model,
|
||||
parent_field_name="costume_id",
|
||||
parent_ids=list(costume_name.keys()),
|
||||
)
|
||||
|
||||
# 4) Build items (stable order: character/prop/scene/costume, then name)
|
||||
items: list[ShotLinkedAssetItem] = []
|
||||
for cid, name in character_name.items():
|
||||
info = character_thumb.get(cid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="character",
|
||||
id=cid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
for pid, name in prop_name.items():
|
||||
info = prop_thumb.get(pid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="prop",
|
||||
id=pid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
for sid, name in scene_name.items():
|
||||
info = scene_thumb.get(sid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="scene",
|
||||
id=sid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
for coid, name in costume_name.items():
|
||||
info = costume_thumb.get(coid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="costume",
|
||||
id=coid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(key=lambda x: (x.type, x.name, x.id))
|
||||
total = len(items)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
return paginated_response(items[start:end], page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
# ---------- ShotDetail ----------
|
||||
@@ -403,12 +208,15 @@ async def list_shot_details(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
) -> ApiResponse[PaginatedData[ShotDetailRead]]:
|
||||
stmt = select(ShotDetail)
|
||||
if shot_id is not None:
|
||||
stmt = stmt.where(ShotDetail.id == shot_id)
|
||||
stmt = apply_order(stmt, model=ShotDetail, order=order, is_desc=is_desc, allow_fields=DETAIL_ORDER_FIELDS, default="id")
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response([ShotDetailRead.model_validate(x) for x in items], page=page, page_size=page_size, total=total)
|
||||
return await list_shot_details_paginated(
|
||||
db,
|
||||
shot_id=shot_id,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
allow_fields=DETAIL_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
@details_router.post(
|
||||
@@ -421,16 +229,8 @@ async def create_shot_detail(
|
||||
body: ShotDetailCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotDetailRead]:
|
||||
exists = await db.get(ShotDetail, body.id)
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail="ShotDetail already exists")
|
||||
await _ensure_shot(db, body.id)
|
||||
await _ensure_scene_optional(db, body.scene_id)
|
||||
obj = ShotDetail(**body.model_dump())
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(ShotDetailRead.model_validate(obj), code=201)
|
||||
obj = await create_shot_detail_service(db, body=body)
|
||||
return created_response(ShotDetailRead.model_validate(obj))
|
||||
|
||||
|
||||
@details_router.get(
|
||||
@@ -442,9 +242,7 @@ async def get_shot_detail(
|
||||
shot_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotDetailRead]:
|
||||
obj = await db.get(ShotDetail, shot_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="ShotDetail not found")
|
||||
obj = await get_shot_detail_service(db, shot_id=shot_id)
|
||||
return success_response(ShotDetailRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -458,16 +256,7 @@ async def update_shot_detail(
|
||||
body: ShotDetailUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotDetailRead]:
|
||||
obj = await db.get(ShotDetail, shot_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="ShotDetail not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "scene_id" in update_data:
|
||||
await _ensure_scene_optional(db, update_data["scene_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(obj, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
obj = await update_shot_detail_service(db, shot_id=shot_id, body=body)
|
||||
return success_response(ShotDetailRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -480,12 +269,8 @@ async def delete_shot_detail(
|
||||
shot_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ShotDetail, shot_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_shot_detail_service(db, shot_id=shot_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
# ---------- ShotDialogLine ----------
|
||||
@@ -505,13 +290,16 @@ async def list_shot_dialog_lines(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
) -> ApiResponse[PaginatedData[ShotDialogLineRead]]:
|
||||
stmt = select(ShotDialogLine)
|
||||
if shot_detail_id is not None:
|
||||
stmt = stmt.where(ShotDialogLine.shot_detail_id == shot_detail_id)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[ShotDialogLine.text])
|
||||
stmt = apply_order(stmt, model=ShotDialogLine, order=order, is_desc=is_desc, allow_fields=DIALOG_ORDER_FIELDS, default="index")
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response([ShotDialogLineRead.model_validate(x) for x in items], page=page, page_size=page_size, total=total)
|
||||
return await list_shot_dialog_lines_paginated(
|
||||
db,
|
||||
shot_detail_id=shot_detail_id,
|
||||
q=q,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
allow_fields=DIALOG_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
@dialog_router.post(
|
||||
@@ -524,15 +312,8 @@ async def create_shot_dialog_line(
|
||||
body: ShotDialogLineCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotDialogLineRead]:
|
||||
if await db.get(ShotDetail, body.shot_detail_id) is None:
|
||||
raise HTTPException(status_code=400, detail="ShotDetail not found")
|
||||
await _ensure_character_optional(db, body.speaker_character_id)
|
||||
await _ensure_character_optional(db, body.target_character_id)
|
||||
obj = ShotDialogLine(**body.model_dump())
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(ShotDialogLineRead.model_validate(obj), code=201)
|
||||
obj = await create_shot_dialog_line_service(db, body=body)
|
||||
return created_response(ShotDialogLineRead.model_validate(obj))
|
||||
|
||||
|
||||
@dialog_router.patch(
|
||||
@@ -545,18 +326,7 @@ async def update_shot_dialog_line(
|
||||
body: ShotDialogLineUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotDialogLineRead]:
|
||||
obj = await db.get(ShotDialogLine, line_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="ShotDialogLine not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "speaker_character_id" in update_data:
|
||||
await _ensure_character_optional(db, update_data["speaker_character_id"])
|
||||
if "target_character_id" in update_data:
|
||||
await _ensure_character_optional(db, update_data["target_character_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(obj, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
obj = await update_shot_dialog_line_service(db, line_id=line_id, body=body)
|
||||
return success_response(ShotDialogLineRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -569,12 +339,8 @@ async def delete_shot_dialog_line(
|
||||
line_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ShotDialogLine, line_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_shot_dialog_line_service(db, line_id=line_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
# ---------- ShotFrameImage ----------
|
||||
@@ -593,23 +359,14 @@ async def list_shot_frame_images(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
) -> ApiResponse[PaginatedData[ShotFrameImageRead]]:
|
||||
stmt = select(ShotFrameImage)
|
||||
if shot_detail_id is not None:
|
||||
stmt = stmt.where(ShotFrameImage.shot_detail_id == shot_detail_id)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=ShotFrameImage,
|
||||
return await list_shot_frame_images_paginated(
|
||||
db,
|
||||
shot_detail_id=shot_detail_id,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=FRAME_IMAGE_ORDER_FIELDS,
|
||||
default="id",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ShotFrameImageRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
allow_fields=FRAME_IMAGE_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
@@ -623,13 +380,8 @@ async def create_shot_frame_image(
|
||||
body: ShotFrameImageCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotFrameImageRead]:
|
||||
if await db.get(ShotDetail, body.shot_detail_id) is None:
|
||||
raise HTTPException(status_code=400, detail="ShotDetail not found")
|
||||
obj = ShotFrameImage(**body.model_dump())
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return success_response(ShotFrameImageRead.model_validate(obj), code=201)
|
||||
obj = await create_shot_frame_image_service(db, body=body)
|
||||
return created_response(ShotFrameImageRead.model_validate(obj))
|
||||
|
||||
|
||||
@frames_router.patch(
|
||||
@@ -642,14 +394,7 @@ async def update_shot_frame_image(
|
||||
body: ShotFrameImageUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ShotFrameImageRead]:
|
||||
obj = await db.get(ShotFrameImage, image_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail="ShotFrameImage not found")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(obj, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
obj = await update_shot_frame_image_service(db, image_id=image_id, body=body)
|
||||
return success_response(ShotFrameImageRead.model_validate(obj))
|
||||
|
||||
|
||||
@@ -662,12 +407,8 @@ async def delete_shot_frame_image(
|
||||
image_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ShotFrameImage, image_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_shot_frame_image_service(db, image_id=image_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
# ---------- Links(镜头引用资产) ----------
|
||||
@@ -690,9 +431,9 @@ async def list_project_entity_links(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
) -> ApiResponse[PaginatedData[Any]]:
|
||||
return await _list_project_asset_links(
|
||||
entity_type=entity_type,
|
||||
return await list_project_asset_links_paginated(
|
||||
db=db,
|
||||
entity_type=entity_type,
|
||||
project_id=project_id,
|
||||
chapter_id=chapter_id,
|
||||
shot_id=shot_id,
|
||||
@@ -701,67 +442,10 @@ async def list_project_entity_links(
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
allow_fields=LINK_ORDER_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
def _link_spec(entity_type: str) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
if t == "actor":
|
||||
return {"model": ProjectActorLink, "field": "actor_id", "read_model": ProjectActorLinkRead}
|
||||
if t == "scene":
|
||||
return {"model": ProjectSceneLink, "field": "scene_id", "read_model": ProjectSceneLinkRead}
|
||||
if t == "prop":
|
||||
return {"model": ProjectPropLink, "field": "prop_id", "read_model": ProjectPropLinkRead}
|
||||
if t == "costume":
|
||||
return {"model": ProjectCostumeLink, "field": "costume_id", "read_model": ProjectCostumeLinkRead}
|
||||
raise HTTPException(status_code=400, detail="entity_type must be one of: actor/scene/prop/costume")
|
||||
|
||||
|
||||
async def _list_project_asset_links(
|
||||
*,
|
||||
entity_type: str,
|
||||
db: AsyncSession,
|
||||
project_id: str | None,
|
||||
chapter_id: str | None,
|
||||
shot_id: str | None,
|
||||
asset_id: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> ApiResponse[PaginatedData[Any]]:
|
||||
spec = _link_spec(entity_type)
|
||||
model = spec["model"]
|
||||
field_name = spec["field"]
|
||||
|
||||
stmt = select(model)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(model.project_id == project_id)
|
||||
if chapter_id is not None:
|
||||
stmt = stmt.where(model.chapter_id == chapter_id)
|
||||
if shot_id is not None:
|
||||
stmt = stmt.where(model.shot_id == shot_id)
|
||||
if asset_id is not None:
|
||||
stmt = stmt.where(getattr(model, field_name) == asset_id)
|
||||
stmt = apply_order(stmt, model=model, order=order, is_desc=is_desc, allow_fields=LINK_ORDER_FIELDS, default="id")
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
|
||||
es = entity_spec(entity_type)
|
||||
ids = [getattr(x, field_name) for x in items if getattr(x, field_name, None)]
|
||||
thumbnails = await resolve_thumbnails(
|
||||
db,
|
||||
image_model=es.image_model,
|
||||
parent_field_name=es.id_field,
|
||||
parent_ids=ids,
|
||||
)
|
||||
read_model = spec["read_model"]
|
||||
payload = [
|
||||
read_model.model_validate(x).model_copy(update={"thumbnail": thumbnails.get(getattr(x, field_name), "")})
|
||||
for x in items
|
||||
]
|
||||
return paginated_response(payload, page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
@links_router.post(
|
||||
"/actor",
|
||||
response_model=ApiResponse[ProjectActorLinkRead],
|
||||
@@ -772,17 +456,8 @@ async def create_project_actor_link(
|
||||
body: ProjectAssetLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectActorLinkRead]:
|
||||
await _ensure_actor(db, body.asset_id)
|
||||
obj = await upsert_project_link(
|
||||
db,
|
||||
model=ProjectActorLink,
|
||||
asset_field="actor_id",
|
||||
asset_id=body.asset_id,
|
||||
project_id=body.project_id,
|
||||
chapter_id=body.chapter_id,
|
||||
shot_id=body.shot_id,
|
||||
)
|
||||
return success_response(ProjectActorLinkRead.model_validate(obj), code=201)
|
||||
obj = await create_project_asset_link_service(db, entity_type="actor", body=body)
|
||||
return created_response(ProjectActorLinkRead.model_validate(obj))
|
||||
|
||||
|
||||
|
||||
@@ -795,12 +470,8 @@ async def delete_project_actor_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ProjectActorLink, link_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_project_asset_link_service(db, entity_type="actor", link_id=link_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
@links_router.post(
|
||||
@@ -813,17 +484,8 @@ async def create_project_scene_link(
|
||||
body: ProjectAssetLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectSceneLinkRead]:
|
||||
await _ensure_scene_optional(db, body.asset_id)
|
||||
obj = await upsert_project_link(
|
||||
db,
|
||||
model=ProjectSceneLink,
|
||||
asset_field="scene_id",
|
||||
asset_id=body.asset_id,
|
||||
project_id=body.project_id,
|
||||
chapter_id=body.chapter_id,
|
||||
shot_id=body.shot_id,
|
||||
)
|
||||
return success_response(ProjectSceneLinkRead.model_validate(obj), code=201)
|
||||
obj = await create_project_asset_link_service(db, entity_type="scene", body=body)
|
||||
return created_response(ProjectSceneLinkRead.model_validate(obj))
|
||||
|
||||
|
||||
|
||||
@@ -836,12 +498,8 @@ async def delete_project_scene_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ProjectSceneLink, link_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_project_asset_link_service(db, entity_type="scene", link_id=link_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
@links_router.post(
|
||||
@@ -854,17 +512,8 @@ async def create_project_prop_link(
|
||||
body: ProjectAssetLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectPropLinkRead]:
|
||||
await _ensure_prop(db, body.asset_id)
|
||||
obj = await upsert_project_link(
|
||||
db,
|
||||
model=ProjectPropLink,
|
||||
asset_field="prop_id",
|
||||
asset_id=body.asset_id,
|
||||
project_id=body.project_id,
|
||||
chapter_id=body.chapter_id,
|
||||
shot_id=body.shot_id,
|
||||
)
|
||||
return success_response(ProjectPropLinkRead.model_validate(obj), code=201)
|
||||
obj = await create_project_asset_link_service(db, entity_type="prop", body=body)
|
||||
return created_response(ProjectPropLinkRead.model_validate(obj))
|
||||
|
||||
|
||||
|
||||
@@ -877,12 +526,8 @@ async def delete_project_prop_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ProjectPropLink, link_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
await delete_project_asset_link_service(db, entity_type="prop", link_id=link_id)
|
||||
return empty_response()
|
||||
|
||||
|
||||
@links_router.post(
|
||||
@@ -895,17 +540,8 @@ async def create_project_costume_link(
|
||||
body: ProjectAssetLinkCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[ProjectCostumeLinkRead]:
|
||||
await _ensure_costume(db, body.asset_id)
|
||||
obj = await upsert_project_link(
|
||||
db,
|
||||
model=ProjectCostumeLink,
|
||||
asset_field="costume_id",
|
||||
asset_id=body.asset_id,
|
||||
project_id=body.project_id,
|
||||
chapter_id=body.chapter_id,
|
||||
shot_id=body.shot_id,
|
||||
)
|
||||
return success_response(ProjectCostumeLinkRead.model_validate(obj), code=201)
|
||||
obj = await create_project_asset_link_service(db, entity_type="costume", body=body)
|
||||
return created_response(ProjectCostumeLinkRead.model_validate(obj))
|
||||
|
||||
|
||||
@links_router.delete(
|
||||
@@ -917,10 +553,5 @@ async def delete_project_costume_link(
|
||||
link_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiResponse[None]:
|
||||
obj = await db.get(ProjectCostumeLink, link_id)
|
||||
if obj is None:
|
||||
return success_response(None)
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
return success_response(None)
|
||||
|
||||
await delete_project_asset_link_service(db, entity_type="costume", link_id=link_id)
|
||||
return empty_response()
|
||||
@@ -43,6 +43,24 @@ def success_response(
|
||||
return ApiResponse(code=code, message=message, data=data)
|
||||
|
||||
|
||||
def created_response(
|
||||
data: T,
|
||||
*,
|
||||
message: str = "success",
|
||||
) -> ApiResponse[T]:
|
||||
"""构造创建成功响应。"""
|
||||
return success_response(data, code=201, message=message)
|
||||
|
||||
|
||||
def empty_response(
|
||||
*,
|
||||
code: int = 200,
|
||||
message: str = "success",
|
||||
) -> ApiResponse[None]:
|
||||
"""构造 data 为 null 的成功响应。"""
|
||||
return ApiResponse(code=code, message=message, data=None)
|
||||
|
||||
|
||||
def paginated_response(
|
||||
items: list[T],
|
||||
*,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.llm import LogLevel, ModelCategoryKey, ProviderStatus
|
||||
|
||||
@@ -47,10 +47,9 @@ class ProviderUpdate(BaseModel):
|
||||
class ProviderRead(ProviderBase):
|
||||
"""对外返回的供应商信息(不包含 api_key/api_secret)。"""
|
||||
|
||||
id: str = Field(..., description="供应商 ID")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
id: str = Field(..., description="供应商 ID")
|
||||
|
||||
|
||||
class ModelBase(BaseModel):
|
||||
@@ -85,10 +84,9 @@ class ModelUpdate(BaseModel):
|
||||
class ModelRead(ModelBase):
|
||||
"""对外返回的模型信息。"""
|
||||
|
||||
id: str = Field(..., description="模型 ID")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
id: str = Field(..., description="模型 ID")
|
||||
|
||||
|
||||
class ModelSettingsBase(BaseModel):
|
||||
@@ -110,8 +108,6 @@ class ModelSettingsUpdate(ModelSettingsBase):
|
||||
class ModelSettingsRead(ModelSettingsBase):
|
||||
"""对外返回的模型全局设置。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(..., description="设置行 ID(通常为 1)")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.models.studio import AssetQualityLevel, AssetViewAngle, ProjectStyle, ProjectVisualStyle
|
||||
|
||||
@@ -62,10 +62,9 @@ class AssetUpdate(BaseModel):
|
||||
|
||||
|
||||
class AssetRead(AssetBase):
|
||||
thumbnail: str = Field("", description="缩略图下载地址")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
thumbnail: str = Field("", description="缩略图下载地址")
|
||||
|
||||
|
||||
class AssetImageBase(BaseModel):
|
||||
@@ -109,29 +108,24 @@ class CostumeRead(AssetRead):
|
||||
|
||||
|
||||
class SceneImageRead(AssetImageBase):
|
||||
scene_id: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
scene_id: str
|
||||
|
||||
|
||||
class PropImageRead(AssetImageBase):
|
||||
prop_id: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
prop_id: str
|
||||
|
||||
|
||||
class CostumeImageRead(AssetImageBase):
|
||||
costume_id: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
costume_id: str
|
||||
|
||||
|
||||
class CharacterImageRead(AssetImageBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
character_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.models.studio import ProjectStyle, ProjectVisualStyle
|
||||
|
||||
@@ -54,10 +54,9 @@ class ActorUpdate(BaseModel):
|
||||
|
||||
|
||||
class ActorRead(ActorBase):
|
||||
thumbnail: str = Field("", description="缩略图下载地址")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
thumbnail: str = Field("", description="缩略图下载地址")
|
||||
|
||||
|
||||
class CharacterBase(BaseModel):
|
||||
@@ -86,10 +85,9 @@ class CharacterUpdate(BaseModel):
|
||||
|
||||
|
||||
class CharacterRead(CharacterBase):
|
||||
thumbnail: str = Field("", description="缩略图下载地址")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
thumbnail: str = Field("", description="缩略图下载地址")
|
||||
|
||||
|
||||
class CharacterPropLinkBase(BaseModel):
|
||||
@@ -113,8 +111,7 @@ class CharacterPropLinkUpdate(BaseModel):
|
||||
|
||||
|
||||
class CharacterPropLinkRead(CharacterPropLinkBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ShotCharacterLinkBase(BaseModel):
|
||||
@@ -138,6 +135,4 @@ class ShotCharacterLinkUpdate(BaseModel):
|
||||
|
||||
|
||||
class ShotCharacterLinkRead(ShotCharacterLinkBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.studio import AssetQualityLevel, AssetViewAngle
|
||||
|
||||
|
||||
class ActorImageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
actor_id: str
|
||||
quality_level: AssetQualityLevel = AssetQualityLevel.low
|
||||
@@ -17,6 +19,3 @@ class ActorImageRead(BaseModel):
|
||||
height: int | None = None
|
||||
format: str = "png"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FileTypeEnum(str, Enum):
|
||||
@@ -41,6 +41,8 @@ class FileUsageWrite(BaseModel):
|
||||
|
||||
|
||||
class FileUsageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
file_id: str
|
||||
project_id: str
|
||||
@@ -49,10 +51,6 @@ class FileUsageRead(BaseModel):
|
||||
usage_kind: str
|
||||
source_ref: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
thumbnail: str | None = None
|
||||
@@ -61,14 +59,12 @@ class FileUpdate(BaseModel):
|
||||
|
||||
|
||||
class FileRead(FileBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FileDetailRead(FileRead):
|
||||
"""含 file_usages 列表(详情接口)。"""
|
||||
|
||||
usages: list[FileUsageRead] = Field(default_factory=list)
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
usages: list[FileUsageRead] = Field(default_factory=list)
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.studio import ChapterStatus, ProjectStyle, ProjectVisualStyle
|
||||
|
||||
@@ -39,10 +39,9 @@ class ProjectUpdate(BaseModel):
|
||||
|
||||
|
||||
class ProjectRead(ProjectBase):
|
||||
id: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
id: str
|
||||
|
||||
|
||||
class ChapterBase(BaseModel):
|
||||
@@ -72,9 +71,7 @@ class ChapterUpdate(BaseModel):
|
||||
|
||||
|
||||
class ChapterRead(ChapterBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
shot_count: int = Field(0, description="分镜数(shots 条数聚合)")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.studio import PromptCategory
|
||||
|
||||
@@ -41,6 +41,8 @@ class PromptTemplateUpdate(BaseModel):
|
||||
class PromptTemplateRead(BaseModel):
|
||||
"""读取提示词模板(含全部字段)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str = Field(..., description="模板 ID")
|
||||
category: PromptCategory = Field(..., description="模板类别")
|
||||
name: str = Field(..., description="模板名称")
|
||||
@@ -52,6 +54,3 @@ class PromptTemplateRead(BaseModel):
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="最后更新时间")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.studio import (
|
||||
CameraAngle,
|
||||
@@ -46,8 +46,7 @@ class ShotUpdate(BaseModel):
|
||||
|
||||
|
||||
class ShotRead(ShotBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ShotDetailBase(BaseModel):
|
||||
@@ -99,8 +98,7 @@ class ShotDetailUpdate(BaseModel):
|
||||
|
||||
|
||||
class ShotDetailRead(ShotDetailBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ShotDialogLineBase(BaseModel):
|
||||
@@ -137,8 +135,7 @@ class ShotDialogLineUpdate(BaseModel):
|
||||
|
||||
|
||||
class ShotDialogLineRead(ShotDialogLineBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProjectLinkBase(BaseModel):
|
||||
@@ -156,36 +153,32 @@ class ProjectAssetLinkCreate(BaseModel):
|
||||
|
||||
|
||||
class ProjectActorLinkRead(ProjectLinkBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
actor_id: str
|
||||
thumbnail: str = Field("", description="演员缩略图下载地址")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectSceneLinkRead(ProjectLinkBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
scene_id: str
|
||||
thumbnail: str = Field("", description="场景缩略图下载地址")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectPropLinkRead(ProjectLinkBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
prop_id: str
|
||||
thumbnail: str = Field("", description="道具缩略图下载地址")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectCostumeLinkRead(ProjectLinkBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
costume_id: str
|
||||
thumbnail: str = Field("", description="服装缩略图下载地址")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ShotFrameImageBase(BaseModel):
|
||||
id: int = Field(..., description="图片行 ID")
|
||||
@@ -215,8 +208,7 @@ class ShotFrameImageUpdate(BaseModel):
|
||||
|
||||
|
||||
class ShotFrameImageRead(ShotFrameImageBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
ShotLinkedAssetType = Literal["character", "prop", "scene", "costume"]
|
||||
@@ -237,4 +229,3 @@ class ShotLinkedAssetItem(BaseModel):
|
||||
)
|
||||
name: str = Field(..., description="实体名称")
|
||||
thumbnail: str = Field("", description="缩略图下载地址(/api/v1/studio/files/{file_id}/download)")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""通用服务层能力:基础校验与 CRUD 助手。"""
|
||||
|
||||
from app.services.common.crud import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
)
|
||||
from app.services.common.errors import (
|
||||
entity_already_exists,
|
||||
entity_not_found,
|
||||
invalid_choice,
|
||||
not_belong_to,
|
||||
relation_mismatch,
|
||||
required_field,
|
||||
)
|
||||
from app.services.common.validators import (
|
||||
ensure_not_exists,
|
||||
require_entity,
|
||||
require_optional_entity,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_and_refresh",
|
||||
"delete_if_exists",
|
||||
"entity_already_exists",
|
||||
"entity_not_found",
|
||||
"ensure_not_exists",
|
||||
"flush_and_refresh",
|
||||
"get_or_404",
|
||||
"invalid_choice",
|
||||
"not_belong_to",
|
||||
"patch_model",
|
||||
"relation_mismatch",
|
||||
"required_field",
|
||||
"require_entity",
|
||||
"require_optional_entity",
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""通用 CRUD 助手:减少路由层样板代码。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.common.validators import require_entity
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
|
||||
|
||||
async def create_and_refresh(db: AsyncSession, obj: ModelT) -> ModelT:
|
||||
"""写入对象并刷新,返回最新状态。"""
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def patch_model(obj: Any, data: dict[str, Any]) -> Any:
|
||||
"""将字典字段更新到模型实例。"""
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
return obj
|
||||
|
||||
|
||||
async def get_or_404(
|
||||
db: AsyncSession,
|
||||
model: type[ModelT],
|
||||
entity_id: Any,
|
||||
*,
|
||||
detail: str,
|
||||
) -> ModelT:
|
||||
"""获取实体,不存在时抛出 404。"""
|
||||
return await require_entity(db, model, entity_id, detail=detail)
|
||||
|
||||
|
||||
async def flush_and_refresh(db: AsyncSession, obj: ModelT) -> ModelT:
|
||||
"""刷新已更新对象并返回。"""
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
async def delete_if_exists(
|
||||
db: AsyncSession,
|
||||
model: type[Any],
|
||||
entity_id: Any,
|
||||
) -> None:
|
||||
"""若实体存在则删除;不存在时静默返回。"""
|
||||
obj = await db.get(model, entity_id)
|
||||
if obj is None:
|
||||
return
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""通用错误文案模板:统一 not found / already exists / required 等消息。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
def entity_not_found(name: str) -> str:
|
||||
"""实体不存在错误。"""
|
||||
return f"{name} not found"
|
||||
|
||||
|
||||
def entity_already_exists(name: str) -> str:
|
||||
"""实体已存在错误。"""
|
||||
return f"{name} already exists"
|
||||
|
||||
|
||||
def required_field(field: str, *, when: str | None = None) -> str:
|
||||
"""必填字段错误。"""
|
||||
if when:
|
||||
return f"{field} is required for {when}"
|
||||
return f"{field} is required"
|
||||
|
||||
|
||||
def invalid_choice(field: str, choices: Sequence[str]) -> str:
|
||||
"""字段取值非法错误。"""
|
||||
return f"{field} must be one of: {'/'.join(choices)}"
|
||||
|
||||
|
||||
def not_belong_to(child_field: str, parent_field: str) -> str:
|
||||
"""归属关系校验错误。"""
|
||||
return f"{child_field} does not belong to given {parent_field}"
|
||||
|
||||
|
||||
def relation_mismatch(child_field: str, parent_field: str) -> str:
|
||||
"""通用归属关系不匹配错误。"""
|
||||
return f"{child_field} does not belong to {parent_field}"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""通用校验工具:实体存在性与重复性检查。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
|
||||
|
||||
async def require_entity(
|
||||
db: AsyncSession,
|
||||
model: type[ModelT],
|
||||
entity_id: Any,
|
||||
*,
|
||||
detail: str,
|
||||
status_code: int = status.HTTP_404_NOT_FOUND,
|
||||
) -> ModelT:
|
||||
"""确保实体存在,并返回实体对象。"""
|
||||
obj = await db.get(model, entity_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
return obj
|
||||
|
||||
|
||||
async def require_optional_entity(
|
||||
db: AsyncSession,
|
||||
model: type[ModelT],
|
||||
entity_id: Any | None,
|
||||
*,
|
||||
detail: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
) -> ModelT | None:
|
||||
"""如果传入了实体 ID,则校验其存在性。"""
|
||||
if entity_id is None:
|
||||
return None
|
||||
return await require_entity(
|
||||
db,
|
||||
model,
|
||||
entity_id,
|
||||
detail=detail,
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_not_exists(
|
||||
db: AsyncSession,
|
||||
model: type[Any],
|
||||
entity_id: Any,
|
||||
*,
|
||||
detail: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
) -> None:
|
||||
"""确保给定主键对应的实体不存在。"""
|
||||
obj = await db.get(model, entity_id)
|
||||
if obj is not None:
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
@@ -0,0 +1,41 @@
|
||||
from app.services.film.generated_video import (
|
||||
REQUIRED_FRAMES_BY_MODE,
|
||||
build_run_args,
|
||||
load_provider_config_by_model,
|
||||
persist_generated_video_to_shot,
|
||||
preview_prompt_and_images,
|
||||
provider_key_from_db_name,
|
||||
required_image_count,
|
||||
resolve_default_video_model,
|
||||
run_video_generation_task,
|
||||
validate_images_count,
|
||||
validate_shot_and_duration,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"REQUIRED_FRAMES_BY_MODE",
|
||||
"build_run_args",
|
||||
"load_provider_config_by_model",
|
||||
"persist_generated_video_to_shot",
|
||||
"preview_prompt_and_images",
|
||||
"provider_key_from_db_name",
|
||||
"required_image_count",
|
||||
"resolve_default_video_model",
|
||||
"run_video_generation_task",
|
||||
"validate_images_count",
|
||||
"validate_shot_and_duration",
|
||||
]
|
||||
|
||||
from app.services.film.shot_frame_prompt_tasks import (
|
||||
build_run_args as build_shot_frame_prompt_run_args,
|
||||
normalize_frame_type,
|
||||
relation_type_for_frame,
|
||||
run_shot_frame_prompt_task,
|
||||
)
|
||||
|
||||
__all__ += [
|
||||
"build_shot_frame_prompt_run_args",
|
||||
"normalize_frame_type",
|
||||
"relation_type_for_frame",
|
||||
"run_shot_frame_prompt_task",
|
||||
]
|
||||
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import storage
|
||||
from app.core.db import async_session_maker
|
||||
from app.core.task_manager import SqlAlchemyTaskStore
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
from app.core.tasks import (
|
||||
ProviderConfig,
|
||||
VideoGenerationInput,
|
||||
VideoGenerationResult,
|
||||
VideoGenerationTask,
|
||||
)
|
||||
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.models.task_links import GenerationTaskLink
|
||||
from app.models.studio import FileItem, Shot, ShotDetail, ShotFrameImage, ShotFrameType
|
||||
from app.models.types import FileUsageKind
|
||||
from app.services.common import entity_not_found
|
||||
from app.services.studio.file_usages import sync_usage_from_shot_context
|
||||
from app.utils.files import create_file_from_url_or_b64
|
||||
|
||||
REQUIRED_FRAMES_BY_MODE: dict[str, tuple[ShotFrameType, ...]] = {
|
||||
"first": (ShotFrameType.first,),
|
||||
"last": (ShotFrameType.last,),
|
||||
"key": (ShotFrameType.key,),
|
||||
"first_last": (ShotFrameType.first, ShotFrameType.last),
|
||||
"first_last_key": (ShotFrameType.first, ShotFrameType.last, ShotFrameType.key),
|
||||
"text_only": (),
|
||||
}
|
||||
|
||||
|
||||
def required_image_count(reference_mode: str) -> int:
|
||||
return len(REQUIRED_FRAMES_BY_MODE[reference_mode])
|
||||
|
||||
|
||||
def validate_images_count(reference_mode: str, images: list[str]) -> None:
|
||||
expected = required_image_count(reference_mode)
|
||||
actual = len(images or [])
|
||||
if actual != expected:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"reference_mode={reference_mode} requires exactly {expected} images, got {actual}",
|
||||
)
|
||||
|
||||
|
||||
async def validate_shot_and_duration(db: AsyncSession, shot_id: str) -> ShotDetail:
|
||||
shot = await db.get(Shot, shot_id)
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Shot"))
|
||||
shot_detail = await db.get(ShotDetail, shot_id)
|
||||
if shot_detail is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("ShotDetail"))
|
||||
if shot_detail.duration is None or shot_detail.duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Shot duration is not configured; please set shot duration first")
|
||||
return shot_detail
|
||||
|
||||
|
||||
async def file_id_to_data_url(db: AsyncSession, *, file_id: str) -> str:
|
||||
file_obj = await db.get(FileItem, file_id)
|
||||
if file_obj is None or not file_obj.storage_key:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}")
|
||||
try:
|
||||
content = await storage.download_file(key=file_obj.storage_key)
|
||||
except Exception: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}") from None
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}")
|
||||
|
||||
content_type: str | None = None
|
||||
try:
|
||||
info = await storage.get_file_info(key=file_obj.storage_key)
|
||||
content_type = (info.content_type or "").strip().lower() or None
|
||||
except Exception: # noqa: BLE001
|
||||
content_type = None
|
||||
if not content_type:
|
||||
guessed_type, _ = mimetypes.guess_type(file_obj.storage_key)
|
||||
content_type = (guessed_type or "").strip().lower() or None
|
||||
if not content_type or not content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid image file_id: {file_id}")
|
||||
|
||||
image_format = content_type.split("/", 1)[1].split(";", 1)[0].strip().lower() or "png"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
return f"data:image/{image_format};base64,{encoded}"
|
||||
|
||||
|
||||
async def preview_prompt_and_images(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
reference_mode: str,
|
||||
prompt: str | None,
|
||||
) -> tuple[str, list[str], ShotDetail]:
|
||||
shot_detail = await validate_shot_and_duration(db, shot_id)
|
||||
required_frames = REQUIRED_FRAMES_BY_MODE[reference_mode]
|
||||
|
||||
frame_map: dict[ShotFrameType, ShotFrameImage] = {}
|
||||
if required_frames:
|
||||
stmt = select(ShotFrameImage).where(
|
||||
ShotFrameImage.shot_detail_id == shot_id,
|
||||
ShotFrameImage.frame_type.in_(required_frames),
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
frame_map = {r.frame_type: r for r in rows}
|
||||
|
||||
missing: list[ShotFrameType] = []
|
||||
for ft in required_frames:
|
||||
row = frame_map.get(ft)
|
||||
if row is None or not row.file_id:
|
||||
missing.append(ft)
|
||||
if missing:
|
||||
missing_name = ",".join(m.value for m in missing)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Required frame image is missing: {missing_name}; please generate it first",
|
||||
)
|
||||
|
||||
prompt_by_mode = {
|
||||
"first": (shot_detail.first_frame_prompt or "").strip(),
|
||||
"last": (shot_detail.last_frame_prompt or "").strip(),
|
||||
"key": (shot_detail.key_frame_prompt or "").strip(),
|
||||
"first_last": "\n".join(
|
||||
p for p in [(shot_detail.first_frame_prompt or "").strip(), (shot_detail.last_frame_prompt or "").strip()] if p
|
||||
),
|
||||
"first_last_key": "\n".join(
|
||||
p
|
||||
for p in [
|
||||
(shot_detail.first_frame_prompt or "").strip(),
|
||||
(shot_detail.last_frame_prompt or "").strip(),
|
||||
(shot_detail.key_frame_prompt or "").strip(),
|
||||
]
|
||||
if p
|
||||
),
|
||||
"text_only": "",
|
||||
}
|
||||
final_prompt = (prompt or "").strip() or prompt_by_mode[reference_mode]
|
||||
if reference_mode == "text_only" and not final_prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required when reference_mode=text_only")
|
||||
|
||||
image_ids = [str(frame_map[ft].file_id) for ft in required_frames if frame_map.get(ft) and frame_map[ft].file_id]
|
||||
return final_prompt, image_ids, shot_detail
|
||||
|
||||
|
||||
def provider_key_from_db_name(name: str) -> str:
|
||||
n = (name or "").strip()
|
||||
n_lower = n.lower()
|
||||
if n_lower == "openai":
|
||||
return "openai"
|
||||
if n == "火山引擎" or "volc" in n_lower or "doubao" in n_lower or "bytedance" in n_lower:
|
||||
return "volcengine"
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Unsupported provider name: {name!r}. Expected: openai, 火山引擎.",
|
||||
)
|
||||
|
||||
|
||||
async def resolve_default_video_model(db: AsyncSession) -> Model:
|
||||
settings_row = await db.get(ModelSettings, 1)
|
||||
model_id = settings_row.default_video_model_id if settings_row else None
|
||||
if not model_id:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No default video model configured; please set ModelSettings.default_video_model_id first",
|
||||
)
|
||||
model = await db.get(Model, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=503, detail=f"Configured default video model not found: {model_id}")
|
||||
if model.category != ModelCategoryKey.video:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Configured default video model is not video category: {model_id} (category={model.category})",
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
async def load_provider_config_by_model(db: AsyncSession, model: Model) -> ProviderConfig:
|
||||
provider = await db.get(Provider, model.provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Provider not found for model.provider_id={model.provider_id}",
|
||||
)
|
||||
provider_key = provider_key_from_db_name(provider.name)
|
||||
api_key = (provider.api_key or "").strip()
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Provider api_key is empty for provider_id={provider.id}",
|
||||
)
|
||||
base_url = (provider.base_url or "").strip() or None
|
||||
return ProviderConfig(provider=provider_key, api_key=api_key, base_url=base_url) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def build_run_args(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
reference_mode: str,
|
||||
prompt: str | None,
|
||||
images: list[str],
|
||||
size: str | None,
|
||||
) -> dict:
|
||||
model = await resolve_default_video_model(db)
|
||||
provider_cfg = await load_provider_config_by_model(db, model)
|
||||
shot_detail = await validate_shot_and_duration(db, shot_id)
|
||||
validate_images_count(reference_mode, images)
|
||||
|
||||
final_prompt = (prompt or "").strip()
|
||||
if not final_prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
required_frames = REQUIRED_FRAMES_BY_MODE[reference_mode]
|
||||
frame_data_urls = [await file_id_to_data_url(db, file_id=file_id) for file_id in images]
|
||||
frame_map = {ft: frame_data_urls[i] for i, ft in enumerate(required_frames)}
|
||||
|
||||
return {
|
||||
"shot_id": shot_id,
|
||||
"provider": provider_cfg.provider,
|
||||
"api_key": provider_cfg.api_key,
|
||||
"base_url": provider_cfg.base_url,
|
||||
"input": {
|
||||
"prompt": final_prompt,
|
||||
"first_frame_base64": frame_map.get(ShotFrameType.first),
|
||||
"last_frame_base64": frame_map.get(ShotFrameType.last),
|
||||
"key_frame_base64": frame_map.get(ShotFrameType.key),
|
||||
"model": model.name,
|
||||
"size": size,
|
||||
"seconds": shot_detail.duration,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def persist_generated_video_to_shot(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_id: str,
|
||||
shot_id: str,
|
||||
result: VideoGenerationResult,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
) -> FileItem:
|
||||
url = (result.url or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("Video generation result has no download url")
|
||||
|
||||
url_headers: dict[str, str] | None = None
|
||||
if provider == "openai":
|
||||
url_headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
file_obj = await create_file_from_url_or_b64(
|
||||
session,
|
||||
url=url,
|
||||
name=f"shot-{shot_id}-video",
|
||||
prefix=f"generated-videos/shots/{shot_id}",
|
||||
url_request_headers=url_headers,
|
||||
httpx_timeout=600.0,
|
||||
)
|
||||
|
||||
link_stmt = (
|
||||
select(GenerationTaskLink)
|
||||
.where(
|
||||
GenerationTaskLink.task_id == task_id,
|
||||
GenerationTaskLink.resource_type == "video",
|
||||
GenerationTaskLink.relation_type == "video",
|
||||
GenerationTaskLink.relation_entity_id == shot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
link_row = (await session.execute(link_stmt)).scalars().first()
|
||||
if link_row is not None:
|
||||
link_row.file_id = file_obj.id
|
||||
|
||||
shot = await session.get(Shot, shot_id)
|
||||
if shot is not None:
|
||||
shot.generated_video_file_id = file_obj.id
|
||||
|
||||
await sync_usage_from_shot_context(
|
||||
session,
|
||||
file_id=file_obj.id,
|
||||
shot_id=shot_id,
|
||||
usage_kind=FileUsageKind.generated_video,
|
||||
source_ref=f"shot:{shot_id}:generated_video",
|
||||
)
|
||||
|
||||
return file_obj
|
||||
|
||||
|
||||
async def run_video_generation_task(
|
||||
*,
|
||||
task_id: str,
|
||||
run_args: dict,
|
||||
) -> None:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
store = SqlAlchemyTaskStore(session)
|
||||
await store.set_status(task_id, TaskStatus.running)
|
||||
await store.set_progress(task_id, 10)
|
||||
await session.commit()
|
||||
|
||||
provider = str(run_args.get("provider") or "")
|
||||
api_key = str(run_args.get("api_key") or "")
|
||||
base_url = run_args.get("base_url")
|
||||
input_dict = dict(run_args.get("input") or {})
|
||||
|
||||
task = VideoGenerationTask(
|
||||
provider_config=ProviderConfig(
|
||||
provider=provider, # type: ignore[arg-type]
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
),
|
||||
input_=VideoGenerationInput.model_validate(input_dict),
|
||||
)
|
||||
await task.run()
|
||||
result = await task.get_result()
|
||||
if result is None:
|
||||
status_dict = await task.status()
|
||||
detailed_error = ""
|
||||
if isinstance(status_dict, dict):
|
||||
detailed_error = str(status_dict.get("error") or "")
|
||||
msg = detailed_error or "Video generation task returned no result"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
shot_id = str(run_args.get("shot_id") or "")
|
||||
if not shot_id:
|
||||
raise RuntimeError("run_args missing shot_id for video persistence")
|
||||
|
||||
file_obj = await persist_generated_video_to_shot(
|
||||
session,
|
||||
task_id=task_id,
|
||||
shot_id=shot_id,
|
||||
result=result,
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
result_payload = result.model_dump()
|
||||
result_payload["file_id"] = file_obj.id
|
||||
await store.set_result(task_id, result_payload)
|
||||
await store.set_progress(task_id, 100)
|
||||
await store.set_status(task_id, TaskStatus.succeeded)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
async with async_session_maker() as s2:
|
||||
store = SqlAlchemyTaskStore(s2)
|
||||
await store.set_error(task_id, str(exc))
|
||||
await store.set_status(task_id, TaskStatus.failed)
|
||||
await s2.commit()
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.chains.agents import (
|
||||
ShotFirstFramePromptAgent,
|
||||
ShotKeyFramePromptAgent,
|
||||
ShotLastFramePromptAgent,
|
||||
)
|
||||
from app.core.db import async_session_maker
|
||||
from app.core.task_manager import SqlAlchemyTaskStore
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
from app.models.studio import Chapter, Shot, ShotDetail
|
||||
from app.services.common import entity_not_found, invalid_choice
|
||||
|
||||
|
||||
def normalize_frame_type(frame_type: str) -> str:
|
||||
value = (frame_type or "").strip().lower()
|
||||
if value not in {"first", "last", "key"}:
|
||||
raise HTTPException(status_code=400, detail=invalid_choice("frame_type", ["first", "last", "key"]))
|
||||
return value
|
||||
|
||||
|
||||
def relation_type_for_frame(frame_type: str) -> str:
|
||||
if frame_type == "first":
|
||||
return "shot_first_frame_prompt"
|
||||
if frame_type == "last":
|
||||
return "shot_last_frame_prompt"
|
||||
return "shot_key_frame_prompt"
|
||||
|
||||
|
||||
async def build_run_args(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
frame_type: str,
|
||||
) -> dict:
|
||||
normalized_frame_type = normalize_frame_type(frame_type)
|
||||
shot_stmt = (
|
||||
select(Shot)
|
||||
.options(
|
||||
selectinload(Shot.detail).selectinload(ShotDetail.dialog_lines),
|
||||
selectinload(Shot.chapter).selectinload(Chapter.project),
|
||||
)
|
||||
.where(Shot.id == shot_id)
|
||||
)
|
||||
shot = (await db.execute(shot_stmt)).scalar_one_or_none()
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Shot"))
|
||||
if shot.detail is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("ShotDetail"))
|
||||
|
||||
detail = shot.detail
|
||||
dialog_summary = "\n".join(line.text for line in (detail.dialog_lines or []) if line.text)
|
||||
project = getattr(getattr(shot, "chapter", None), "project", None)
|
||||
visual_style = str(getattr(project, "visual_style", "") or "")
|
||||
style = str(getattr(project, "style", "") or "")
|
||||
|
||||
return {
|
||||
"shot_id": shot_id,
|
||||
"frame_type": normalized_frame_type,
|
||||
"input": {
|
||||
"script_excerpt": shot.script_excerpt or "",
|
||||
"title": shot.title or "",
|
||||
"camera_shot": detail.camera_shot.value if hasattr(detail.camera_shot, "value") else str(detail.camera_shot),
|
||||
"angle": detail.angle.value if hasattr(detail.angle, "value") else str(detail.angle),
|
||||
"movement": detail.movement.value if hasattr(detail.movement, "value") else str(detail.movement),
|
||||
"atmosphere": detail.atmosphere or "",
|
||||
"mood_tags": detail.mood_tags or [],
|
||||
"vfx_type": detail.vfx_type.value if hasattr(detail.vfx_type, "value") else str(detail.vfx_type),
|
||||
"vfx_note": detail.vfx_note or "",
|
||||
"duration": detail.duration,
|
||||
"scene_id": detail.scene_id,
|
||||
"dialog_summary": dialog_summary,
|
||||
"visual_style": visual_style,
|
||||
"style": style,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def run_shot_frame_prompt_task(
|
||||
*,
|
||||
task_id: str,
|
||||
run_args: dict,
|
||||
llm: BaseChatModel,
|
||||
) -> None:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
store = SqlAlchemyTaskStore(session)
|
||||
await store.set_status(task_id, TaskStatus.running)
|
||||
await store.set_progress(task_id, 10)
|
||||
await session.commit()
|
||||
|
||||
frame_type = str(run_args.get("frame_type") or "")
|
||||
shot_id = str(run_args.get("shot_id") or "")
|
||||
input_dict = dict(run_args.get("input") or {})
|
||||
|
||||
if frame_type == "first":
|
||||
agent = ShotFirstFramePromptAgent(llm)
|
||||
elif frame_type == "last":
|
||||
agent = ShotLastFramePromptAgent(llm)
|
||||
else:
|
||||
agent = ShotKeyFramePromptAgent(llm)
|
||||
result = await agent.aextract(**input_dict)
|
||||
|
||||
if not shot_id:
|
||||
raise RuntimeError("Missing shot_id in run args")
|
||||
shot_detail = await session.get(ShotDetail, shot_id)
|
||||
if shot_detail is None:
|
||||
raise RuntimeError("ShotDetail not found when persisting prompt")
|
||||
|
||||
if frame_type == "first":
|
||||
shot_detail.first_frame_prompt = result.prompt
|
||||
elif frame_type == "last":
|
||||
shot_detail.last_frame_prompt = result.prompt
|
||||
else:
|
||||
shot_detail.key_frame_prompt = result.prompt
|
||||
|
||||
await store.set_result(task_id, result.model_dump())
|
||||
await store.set_progress(task_id, 100)
|
||||
await store.set_status(task_id, TaskStatus.succeeded)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
async with async_session_maker() as s2:
|
||||
store = SqlAlchemyTaskStore(s2)
|
||||
await store.set_error(task_id, str(exc))
|
||||
await store.set_status(task_id, TaskStatus.failed)
|
||||
await s2.commit()
|
||||
@@ -1,3 +1,18 @@
|
||||
from app.services.llm.manage import (
|
||||
create_model,
|
||||
create_provider,
|
||||
delete_model,
|
||||
delete_provider,
|
||||
get_model,
|
||||
get_model_settings,
|
||||
get_or_create_settings,
|
||||
get_provider,
|
||||
list_models_paginated,
|
||||
list_providers_paginated,
|
||||
update_model,
|
||||
update_model_settings,
|
||||
update_provider,
|
||||
)
|
||||
from app.services.llm.resolver import (
|
||||
build_chat_model_from_provider,
|
||||
get_default_model_by_category,
|
||||
@@ -7,10 +22,22 @@ from app.services.llm.resolver import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_model",
|
||||
"create_provider",
|
||||
"get_default_model_by_category",
|
||||
"delete_model",
|
||||
"delete_provider",
|
||||
"get_model_by_category",
|
||||
"get_model",
|
||||
"get_model_settings",
|
||||
"get_or_create_settings",
|
||||
"get_provider_by_id_or_obj",
|
||||
"get_provider_by_model_or_id",
|
||||
"get_provider",
|
||||
"list_models_paginated",
|
||||
"list_providers_paginated",
|
||||
"update_model",
|
||||
"update_model_settings",
|
||||
"update_provider",
|
||||
"build_chat_model_from_provider",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""LLM 管理服务:Provider / Model / ModelSettings 的查询与 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.llm import (
|
||||
ModelCreate,
|
||||
ModelRead,
|
||||
ModelSettingsUpdate,
|
||||
ModelUpdate,
|
||||
ProviderCreate,
|
||||
ProviderRead,
|
||||
ProviderUpdate,
|
||||
)
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_already_exists,
|
||||
entity_not_found,
|
||||
ensure_not_exists,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
require_entity,
|
||||
)
|
||||
|
||||
|
||||
async def list_providers_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
q: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[ProviderRead]]:
|
||||
"""分页查询供应商。"""
|
||||
stmt = select(Provider)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[Provider.name, Provider.description])
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=Provider,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="created_at",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ProviderRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create_provider(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ProviderCreate,
|
||||
) -> Provider:
|
||||
"""创建供应商。"""
|
||||
await ensure_not_exists(
|
||||
db,
|
||||
Provider,
|
||||
body.id,
|
||||
detail=entity_already_exists("Provider"),
|
||||
status_code=400,
|
||||
)
|
||||
return await create_and_refresh(
|
||||
db,
|
||||
Provider(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
base_url=body.base_url,
|
||||
api_key=body.api_key,
|
||||
api_secret=body.api_secret,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
created_by=body.created_by,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_provider(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
provider_id: str,
|
||||
) -> Provider:
|
||||
"""获取供应商。"""
|
||||
return await get_or_404(db, Provider, provider_id, detail=entity_not_found("Provider"))
|
||||
|
||||
|
||||
async def update_provider(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
provider_id: str,
|
||||
body: ProviderUpdate,
|
||||
) -> Provider:
|
||||
"""更新供应商。"""
|
||||
provider = await get_or_404(db, Provider, provider_id, detail=entity_not_found("Provider"))
|
||||
patch_model(provider, body.model_dump(exclude_unset=True))
|
||||
return await flush_and_refresh(db, provider)
|
||||
|
||||
|
||||
async def delete_provider(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
provider_id: str,
|
||||
) -> None:
|
||||
"""删除供应商。"""
|
||||
await delete_if_exists(db, Provider, provider_id)
|
||||
|
||||
|
||||
async def list_models_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
provider_id: str | None,
|
||||
category: ModelCategoryKey | None,
|
||||
q: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[ModelRead]]:
|
||||
"""分页查询模型。"""
|
||||
stmt = select(Model)
|
||||
if provider_id is not None:
|
||||
stmt = stmt.where(Model.provider_id == provider_id)
|
||||
if category is not None:
|
||||
stmt = stmt.where(Model.category == category)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[Model.name, Model.description])
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=Model,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="created_at",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ModelRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create_model(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ModelCreate,
|
||||
) -> Model:
|
||||
"""创建模型。"""
|
||||
await ensure_not_exists(
|
||||
db,
|
||||
Model,
|
||||
body.id,
|
||||
detail=entity_already_exists("Model"),
|
||||
status_code=400,
|
||||
)
|
||||
await require_entity(db, Provider, body.provider_id, detail=entity_not_found("Provider"), status_code=400)
|
||||
if body.is_default:
|
||||
await db.execute(update(Model).where(Model.category == body.category).values(is_default=False))
|
||||
await db.flush()
|
||||
return await create_and_refresh(
|
||||
db,
|
||||
Model(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
category=body.category,
|
||||
provider_id=body.provider_id,
|
||||
params=body.params,
|
||||
description=body.description,
|
||||
is_default=body.is_default,
|
||||
created_by=body.created_by,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_model(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
model_id: str,
|
||||
) -> Model:
|
||||
"""获取模型。"""
|
||||
return await get_or_404(db, Model, model_id, detail=entity_not_found("Model"))
|
||||
|
||||
|
||||
async def update_model(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
model_id: str,
|
||||
body: ModelUpdate,
|
||||
) -> Model:
|
||||
"""更新模型。"""
|
||||
model = await get_or_404(db, Model, model_id, detail=entity_not_found("Model"))
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "provider_id" in update_data:
|
||||
await require_entity(
|
||||
db,
|
||||
Provider,
|
||||
update_data["provider_id"],
|
||||
detail=entity_not_found("Provider"),
|
||||
status_code=400,
|
||||
)
|
||||
if update_data.get("is_default") is True:
|
||||
target_category = update_data.get("category", model.category)
|
||||
await db.execute(update(Model).where(Model.category == target_category).values(is_default=False))
|
||||
await db.flush()
|
||||
patch_model(model, update_data)
|
||||
return await flush_and_refresh(db, model)
|
||||
|
||||
|
||||
async def delete_model(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
model_id: str,
|
||||
) -> None:
|
||||
"""删除模型。"""
|
||||
await delete_if_exists(db, Model, model_id)
|
||||
|
||||
|
||||
async def get_or_create_settings(
|
||||
db: AsyncSession,
|
||||
) -> ModelSettings:
|
||||
"""获取或创建单例设置。"""
|
||||
settings = await db.get(ModelSettings, 1)
|
||||
if settings is None:
|
||||
settings = await create_and_refresh(db, ModelSettings(id=1))
|
||||
return settings
|
||||
|
||||
|
||||
async def get_model_settings(
|
||||
db: AsyncSession,
|
||||
) -> ModelSettings:
|
||||
"""获取模型全局设置。"""
|
||||
return await get_or_create_settings(db)
|
||||
|
||||
|
||||
async def update_model_settings(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ModelSettingsUpdate,
|
||||
) -> ModelSettings:
|
||||
"""更新模型全局设置。"""
|
||||
settings = await get_or_create_settings(db)
|
||||
patch_model(settings, body.model_dump())
|
||||
return await flush_and_refresh(db, settings)
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.services.common import entity_not_found
|
||||
|
||||
|
||||
def _settings_model_id(settings_row: ModelSettings | None, category: ModelCategoryKey) -> str | None:
|
||||
@@ -86,7 +87,7 @@ async def _resolve_model(db: AsyncSession, model_or_id: Model | str) -> Model:
|
||||
return model_or_id
|
||||
model = await db.get(Model, model_or_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Model"))
|
||||
return model if isinstance(model, Model) else cast(Model, cast(Any, model))
|
||||
|
||||
|
||||
@@ -95,7 +96,7 @@ async def _resolve_provider(db: AsyncSession, provider_or_id: Provider | str) ->
|
||||
return provider_or_id
|
||||
provider = await db.get(Provider, provider_or_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Provider"))
|
||||
return provider if isinstance(provider, Provider) else cast(Provider, cast(Any, provider))
|
||||
|
||||
|
||||
@@ -159,4 +160,3 @@ async def build_chat_model_from_provider(
|
||||
kwargs.setdefault("base_url", base_url)
|
||||
|
||||
return ChatOpenAI(**kwargs)
|
||||
|
||||
@@ -1,21 +1,150 @@
|
||||
"""Studio 业务服务。"""
|
||||
|
||||
from app.services.studio.shot_character_links import (
|
||||
list_by_shot as list_shot_character_links,
|
||||
upsert as upsert_shot_character_link,
|
||||
)
|
||||
from app.services.studio.script_division import write_division_result_to_chapter
|
||||
from app.services.studio.files import (
|
||||
build_download_response,
|
||||
delete_file,
|
||||
get_file_detail,
|
||||
get_storage_info,
|
||||
list_files_paginated,
|
||||
update_file_meta,
|
||||
upload_file,
|
||||
)
|
||||
from app.services.studio.image_task_prompts import (
|
||||
build_actor_prompt_and_refs,
|
||||
build_asset_prompt_and_refs,
|
||||
build_character_prompt_and_refs,
|
||||
build_shot_frame_prompt_and_refs,
|
||||
)
|
||||
from app.services.studio.image_task_references import (
|
||||
pick_front_ref_file_id,
|
||||
pick_ordered_ref_file_ids,
|
||||
resolve_reference_file_ids_and_names_from_linked_items,
|
||||
resolve_reference_image_refs_by_file_ids,
|
||||
)
|
||||
from app.services.studio.image_task_runner import create_image_task_and_link
|
||||
from app.services.studio.image_task_validation import (
|
||||
validate_actor_image,
|
||||
validate_asset_image_and_relation_type,
|
||||
validate_character_image,
|
||||
)
|
||||
from app.services.studio.shot_assets import (
|
||||
create_project_asset_link,
|
||||
delete_project_asset_link,
|
||||
list_project_asset_links_paginated,
|
||||
list_shot_linked_assets_paginated,
|
||||
)
|
||||
from app.services.studio.shots import (
|
||||
create as create_shot,
|
||||
delete as delete_shot,
|
||||
get as get_shot,
|
||||
list_paginated as list_shots_paginated,
|
||||
update as update_shot,
|
||||
)
|
||||
from app.services.studio.shot_details import (
|
||||
create as create_shot_detail,
|
||||
delete as delete_shot_detail,
|
||||
get as get_shot_detail,
|
||||
list_paginated as list_shot_details_paginated,
|
||||
update as update_shot_detail,
|
||||
)
|
||||
from app.services.studio.shot_dialogs import (
|
||||
create as create_shot_dialog_line,
|
||||
delete as delete_shot_dialog_line,
|
||||
list_paginated as list_shot_dialog_lines_paginated,
|
||||
update as update_shot_dialog_line,
|
||||
)
|
||||
from app.services.studio.shot_frames import (
|
||||
create as create_shot_frame_image,
|
||||
delete as delete_shot_frame_image,
|
||||
list_paginated as list_shot_frame_images_paginated,
|
||||
update as update_shot_frame_image,
|
||||
)
|
||||
from app.services.studio.entities import (
|
||||
StudioEntitiesService,
|
||||
download_url,
|
||||
entity_spec,
|
||||
normalize_entity_type,
|
||||
resolve_thumbnails,
|
||||
resolve_thumbnail_infos,
|
||||
)
|
||||
from app.services.studio.entity_crud import (
|
||||
create_entity,
|
||||
delete_entity,
|
||||
get_entity,
|
||||
list_entities_paginated,
|
||||
update_entity,
|
||||
)
|
||||
from app.services.studio.entity_existence import check_names_existence
|
||||
from app.services.studio.entity_images import (
|
||||
create_entity_image,
|
||||
delete_entity_image,
|
||||
list_entity_images_paginated,
|
||||
update_entity_image,
|
||||
)
|
||||
from app.services.studio.entity_specs import entity_spec, normalize_entity_type
|
||||
from app.services.studio.entity_thumbnails import download_url, resolve_thumbnail_infos, resolve_thumbnails
|
||||
|
||||
__all__ = [
|
||||
"StudioEntitiesService",
|
||||
"create_entity",
|
||||
"create_entity_image",
|
||||
"check_names_existence",
|
||||
"download_url",
|
||||
"entity_spec",
|
||||
"build_download_response",
|
||||
"create_project_asset_link",
|
||||
"create_image_task_and_link",
|
||||
"create_shot",
|
||||
"delete_file",
|
||||
"delete_entity",
|
||||
"delete_entity_image",
|
||||
"delete_project_asset_link",
|
||||
"delete_shot",
|
||||
"get_file_detail",
|
||||
"get_entity",
|
||||
"get_storage_info",
|
||||
"get_shot",
|
||||
"list_files_paginated",
|
||||
"list_entity_images_paginated",
|
||||
"list_entities_paginated",
|
||||
"list_project_asset_links_paginated",
|
||||
"list_shot_character_links",
|
||||
"list_shot_details_paginated",
|
||||
"list_shot_dialog_lines_paginated",
|
||||
"list_shot_frame_images_paginated",
|
||||
"list_shot_linked_assets_paginated",
|
||||
"list_shots_paginated",
|
||||
"normalize_entity_type",
|
||||
"resolve_thumbnails",
|
||||
"resolve_thumbnail_infos",
|
||||
"resolve_reference_file_ids_and_names_from_linked_items",
|
||||
"resolve_reference_image_refs_by_file_ids",
|
||||
"create_shot_detail",
|
||||
"get_shot_detail",
|
||||
"update_shot_detail",
|
||||
"delete_shot_detail",
|
||||
"create_shot_dialog_line",
|
||||
"update_shot_dialog_line",
|
||||
"delete_shot_dialog_line",
|
||||
"create_shot_frame_image",
|
||||
"update_shot_frame_image",
|
||||
"delete_shot_frame_image",
|
||||
"build_actor_prompt_and_refs",
|
||||
"build_asset_prompt_and_refs",
|
||||
"build_character_prompt_and_refs",
|
||||
"build_shot_frame_prompt_and_refs",
|
||||
"pick_front_ref_file_id",
|
||||
"pick_ordered_ref_file_ids",
|
||||
"update_shot",
|
||||
"upload_file",
|
||||
"update_file_meta",
|
||||
"update_entity_image",
|
||||
"update_entity",
|
||||
"validate_actor_image",
|
||||
"validate_asset_image_and_relation_type",
|
||||
"validate_character_image",
|
||||
"write_division_result_to_chapter",
|
||||
"upsert_shot_character_link",
|
||||
]
|
||||
|
||||
# image_tasks 依赖存储/第三方 SDK(如 boto3);在某些轻量环境中可能未安装。
|
||||
|
||||
@@ -1,226 +1,27 @@
|
||||
"""Studio 实体与实体图片的通用服务。"""
|
||||
"""Studio 实体与实体图片的协调服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.models.studio import (
|
||||
Actor,
|
||||
ActorImage,
|
||||
AssetViewAngle,
|
||||
Chapter,
|
||||
Character,
|
||||
CharacterImage,
|
||||
Costume,
|
||||
CostumeImage,
|
||||
Project,
|
||||
ProjectActorLink,
|
||||
ProjectCostumeLink,
|
||||
ProjectPropLink,
|
||||
ProjectSceneLink,
|
||||
Prop,
|
||||
PropImage,
|
||||
Scene,
|
||||
SceneImage,
|
||||
Shot,
|
||||
ShotCharacterLink,
|
||||
from app.services.studio.entity_crud import (
|
||||
create_entity as create_entity_service,
|
||||
delete_entity as delete_entity_service,
|
||||
get_entity as get_entity_service,
|
||||
list_entities_paginated,
|
||||
update_entity as update_entity_service,
|
||||
)
|
||||
from app.schemas.studio.assets import (
|
||||
AssetCreate,
|
||||
AssetImageCreate,
|
||||
AssetImageUpdate,
|
||||
AssetUpdate,
|
||||
CharacterImageRead,
|
||||
CostumeImageRead,
|
||||
PropImageRead,
|
||||
SceneImageRead,
|
||||
from app.services.studio.entity_existence import check_names_existence as check_names_existence_service
|
||||
from app.services.studio.entity_images import (
|
||||
create_entity_image as create_entity_image_service,
|
||||
delete_entity_image as delete_entity_image_service,
|
||||
list_entity_images_paginated,
|
||||
update_entity_image as update_entity_image_service,
|
||||
)
|
||||
from app.schemas.studio.cast import ActorCreate, ActorRead, ActorUpdate, CharacterCreate, CharacterRead, CharacterUpdate
|
||||
from app.schemas.studio.cast_images import ActorImageRead
|
||||
from app.utils.project_links import upsert_project_link
|
||||
|
||||
ENTITY_ORDER_FIELDS = {"name", "style", "visual_style", "created_at", "updated_at"}
|
||||
IMAGE_ORDER_FIELDS = {"id", "quality_level", "view_angle", "created_at", "updated_at"}
|
||||
DOWNLOAD_URL_TEMPLATE = "/api/v1/studio/files/{file_id}/download"
|
||||
DEFAULT_VIEW_ANGLES: tuple[AssetViewAngle, ...] = (
|
||||
AssetViewAngle.front,
|
||||
AssetViewAngle.left,
|
||||
AssetViewAngle.right,
|
||||
AssetViewAngle.back,
|
||||
)
|
||||
|
||||
_LINK_MODEL_BY_ENTITY: dict[str, tuple[type, str]] = {
|
||||
"actor": (ProjectActorLink, "actor_id"),
|
||||
"scene": (ProjectSceneLink, "scene_id"),
|
||||
"prop": (ProjectPropLink, "prop_id"),
|
||||
"costume": (ProjectCostumeLink, "costume_id"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntitySpec:
|
||||
model: type
|
||||
image_model: type
|
||||
id_field: str
|
||||
read_model: type | None
|
||||
create_model: type
|
||||
update_model: type
|
||||
image_read_model: type
|
||||
image_create_model: type
|
||||
image_update_model: type
|
||||
|
||||
|
||||
def download_url(file_id: str) -> str:
|
||||
return DOWNLOAD_URL_TEMPLATE.format(file_id=file_id)
|
||||
|
||||
|
||||
async def resolve_thumbnails(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_model: type,
|
||||
parent_field_name: str,
|
||||
parent_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
if not parent_ids:
|
||||
return {}
|
||||
parent_field = getattr(image_model, parent_field_name)
|
||||
stmt = select(image_model).where(parent_field.in_(parent_ids), image_model.file_id.is_not(None))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
best: dict[str, tuple[int, int, int, str]] = {}
|
||||
for row in rows:
|
||||
file_id = row.file_id
|
||||
if not file_id:
|
||||
continue
|
||||
parent_id = getattr(row, parent_field_name)
|
||||
created_ts = int(row.created_at.timestamp()) if row.created_at else -1
|
||||
score = (1 if row.view_angle == AssetViewAngle.front else 0, created_ts, row.id)
|
||||
current = best.get(parent_id)
|
||||
if current is None or score > current[:3]:
|
||||
best[parent_id] = (*score, file_id)
|
||||
return {parent_id: download_url(score[3]) for parent_id, score in best.items()}
|
||||
|
||||
|
||||
async def resolve_thumbnail_infos(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_model: type,
|
||||
parent_field_name: str,
|
||||
parent_ids: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""解析每个 parent_id 的最佳缩略图信息(thumbnail + image_id)。
|
||||
|
||||
评分规则与 `resolve_thumbnails` 保持一致:
|
||||
- 优先 view_angle=front
|
||||
- 其次 created_at 新的优先
|
||||
- 其次 image 行 id 大的优先
|
||||
"""
|
||||
|
||||
if not parent_ids:
|
||||
return {}
|
||||
parent_field = getattr(image_model, parent_field_name)
|
||||
stmt = select(image_model).where(parent_field.in_(parent_ids), image_model.file_id.is_not(None))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
best: dict[str, tuple[int, int, int, int, str]] = {}
|
||||
for row in rows:
|
||||
file_id = row.file_id
|
||||
if not file_id:
|
||||
continue
|
||||
parent_id = getattr(row, parent_field_name)
|
||||
created_ts = int(row.created_at.timestamp()) if row.created_at else -1
|
||||
image_id = int(row.id)
|
||||
score3 = (1 if row.view_angle == AssetViewAngle.front else 0, created_ts, image_id)
|
||||
current = best.get(parent_id)
|
||||
if current is None or score3 > current[:3]:
|
||||
best[parent_id] = (*score3, image_id, file_id)
|
||||
|
||||
return {
|
||||
parent_id: {
|
||||
"image_id": info[3],
|
||||
"file_id": info[4],
|
||||
"thumbnail": download_url(info[4]),
|
||||
}
|
||||
for parent_id, info in best.items()
|
||||
}
|
||||
|
||||
|
||||
def normalize_entity_type(entity_type: str) -> str:
|
||||
t = entity_type.strip().lower()
|
||||
if t not in {"actor", "character", "scene", "prop", "costume"}:
|
||||
raise HTTPException(status_code=400, detail="entity_type must be one of: actor/character/scene/prop/costume")
|
||||
return t
|
||||
|
||||
|
||||
def entity_spec(entity_type: str) -> EntitySpec:
|
||||
t = normalize_entity_type(entity_type)
|
||||
if t == "actor":
|
||||
return EntitySpec(
|
||||
model=Actor,
|
||||
image_model=ActorImage,
|
||||
id_field="actor_id",
|
||||
read_model=ActorRead,
|
||||
create_model=ActorCreate,
|
||||
update_model=ActorUpdate,
|
||||
image_read_model=ActorImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
if t == "character":
|
||||
return EntitySpec(
|
||||
model=Character,
|
||||
image_model=CharacterImage,
|
||||
id_field="character_id",
|
||||
read_model=CharacterRead,
|
||||
create_model=CharacterCreate,
|
||||
update_model=CharacterUpdate,
|
||||
image_read_model=CharacterImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
if t == "scene":
|
||||
return EntitySpec(
|
||||
model=Scene,
|
||||
image_model=SceneImage,
|
||||
id_field="scene_id",
|
||||
read_model=None,
|
||||
create_model=AssetCreate,
|
||||
update_model=AssetUpdate,
|
||||
image_read_model=SceneImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
if t == "prop":
|
||||
return EntitySpec(
|
||||
model=Prop,
|
||||
image_model=PropImage,
|
||||
id_field="prop_id",
|
||||
read_model=None,
|
||||
create_model=AssetCreate,
|
||||
update_model=AssetUpdate,
|
||||
image_read_model=PropImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
return EntitySpec(
|
||||
model=Costume,
|
||||
image_model=CostumeImage,
|
||||
id_field="costume_id",
|
||||
read_model=None,
|
||||
create_model=AssetCreate,
|
||||
update_model=AssetUpdate,
|
||||
image_read_model=CostumeImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
|
||||
|
||||
class StudioEntitiesService:
|
||||
"""封装 studio 通用实体与图片的数据库操作。"""
|
||||
"""封装 studio 通用实体与图片的协调调用。"""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self._db = db
|
||||
@@ -234,362 +35,17 @@ class StudioEntitiesService:
|
||||
prop_names: list[str],
|
||||
scene_names: list[str],
|
||||
costume_names: list[str],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""批量检测名称是否存在(模糊匹配),并标记是否已关联到项目。
|
||||
|
||||
约定:
|
||||
- character:仅在该 project_id 下查找(Character.project_id)。
|
||||
- prop/scene/costume:exists 在全局资产表中查找;linked_to_project 通过 Project*Link 关联表判断。
|
||||
- linked_to_shot:仅当传入 shot_id 时检测;角色查 ShotCharacterLink,其余查 Project*Link(shot_id 精确匹配)。
|
||||
- 匹配规则:包含匹配(case-insensitive):name ILIKE %q%
|
||||
"""
|
||||
effective_shot_id = shot_id.strip() if shot_id and str(shot_id).strip() else None
|
||||
if effective_shot_id:
|
||||
shot_ok = (
|
||||
await self._db.execute(
|
||||
select(Shot.id)
|
||||
.join(Chapter, Shot.chapter_id == Chapter.id)
|
||||
.where(Shot.id == effective_shot_id, Chapter.project_id == project_id)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if shot_ok is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found or not in this project")
|
||||
|
||||
async def _exists_in_character(q: str) -> bool:
|
||||
stmt = (
|
||||
select(Character.id)
|
||||
.where(Character.project_id == project_id, Character.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._db.execute(stmt)).scalar_one_or_none() is not None
|
||||
|
||||
async def _find_character_id(q: str) -> str | None:
|
||||
stmt = (
|
||||
select(Character.id)
|
||||
.where(Character.project_id == project_id, Character.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await self._db.execute(stmt)).scalar_one_or_none()
|
||||
return str(row) if row is not None else None
|
||||
|
||||
async def _exists_in_asset(model: type, q: str) -> bool:
|
||||
stmt = select(getattr(model, "id")).where(getattr(model, "name").ilike(f"%{q}%")).limit(1)
|
||||
return (await self._db.execute(stmt)).scalar_one_or_none() is not None
|
||||
|
||||
async def _find_asset_id(model: type, q: str) -> str | None:
|
||||
stmt = select(getattr(model, "id")).where(getattr(model, "name").ilike(f"%{q}%")).limit(1)
|
||||
row = (await self._db.execute(stmt)).scalar_one_or_none()
|
||||
return str(row) if row is not None else None
|
||||
|
||||
async def _linked_prop(q: str) -> bool:
|
||||
stmt = (
|
||||
select(ProjectPropLink.id)
|
||||
.join(Prop, Prop.id == ProjectPropLink.prop_id)
|
||||
.where(ProjectPropLink.project_id == project_id, Prop.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._db.execute(stmt)).scalar_one_or_none() is not None
|
||||
|
||||
async def _find_linked_prop(q: str) -> tuple[int, str] | None:
|
||||
stmt = (
|
||||
select(ProjectPropLink.id, Prop.id)
|
||||
.join(Prop, Prop.id == ProjectPropLink.prop_id)
|
||||
.where(ProjectPropLink.project_id == project_id, Prop.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await self._db.execute(stmt)).first()
|
||||
if not row:
|
||||
return None
|
||||
link_id, prop_id = row
|
||||
return int(link_id), str(prop_id)
|
||||
|
||||
async def _linked_scene(q: str) -> bool:
|
||||
stmt = (
|
||||
select(ProjectSceneLink.id)
|
||||
.join(Scene, Scene.id == ProjectSceneLink.scene_id)
|
||||
.where(ProjectSceneLink.project_id == project_id, Scene.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._db.execute(stmt)).scalar_one_or_none() is not None
|
||||
|
||||
async def _find_linked_scene(q: str) -> tuple[int, str] | None:
|
||||
stmt = (
|
||||
select(ProjectSceneLink.id, Scene.id)
|
||||
.join(Scene, Scene.id == ProjectSceneLink.scene_id)
|
||||
.where(ProjectSceneLink.project_id == project_id, Scene.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await self._db.execute(stmt)).first()
|
||||
if not row:
|
||||
return None
|
||||
link_id, scene_id = row
|
||||
return int(link_id), str(scene_id)
|
||||
|
||||
async def _linked_costume(q: str) -> bool:
|
||||
stmt = (
|
||||
select(ProjectCostumeLink.id)
|
||||
.join(Costume, Costume.id == ProjectCostumeLink.costume_id)
|
||||
.where(ProjectCostumeLink.project_id == project_id, Costume.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._db.execute(stmt)).scalar_one_or_none() is not None
|
||||
|
||||
async def _find_linked_costume(q: str) -> tuple[int, str] | None:
|
||||
stmt = (
|
||||
select(ProjectCostumeLink.id, Costume.id)
|
||||
.join(Costume, Costume.id == ProjectCostumeLink.costume_id)
|
||||
.where(ProjectCostumeLink.project_id == project_id, Costume.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await self._db.execute(stmt)).first()
|
||||
if not row:
|
||||
return None
|
||||
link_id, costume_id = row
|
||||
return int(link_id), str(costume_id)
|
||||
|
||||
characters_out: list[dict[str, Any]] = []
|
||||
for name in character_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
characters_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": False,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": None,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
char_id = await _find_character_id(q)
|
||||
exists = char_id is not None
|
||||
characters_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": exists,
|
||||
"linked_to_project": exists,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": char_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
props_out: list[dict[str, Any]] = []
|
||||
for name in prop_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
props_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": False,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": None,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
linked_row = await _find_linked_prop(q)
|
||||
if linked_row is not None:
|
||||
link_id, prop_id = linked_row
|
||||
props_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": prop_id,
|
||||
"link_id": link_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
prop_id = await _find_asset_id(Prop, q)
|
||||
exists = prop_id is not None
|
||||
props_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": exists,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": prop_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
scenes_out: list[dict[str, Any]] = []
|
||||
for name in scene_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
scenes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": False,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": None,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
linked_row = await _find_linked_scene(q)
|
||||
if linked_row is not None:
|
||||
link_id, scene_id = linked_row
|
||||
scenes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": scene_id,
|
||||
"link_id": link_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
scene_id = await _find_asset_id(Scene, q)
|
||||
exists = scene_id is not None
|
||||
scenes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": exists,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": scene_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
costumes_out: list[dict[str, Any]] = []
|
||||
for name in costume_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
costumes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": False,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": None,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
linked_row = await _find_linked_costume(q)
|
||||
if linked_row is not None:
|
||||
link_id, costume_id = linked_row
|
||||
costumes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": costume_id,
|
||||
"link_id": link_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
costume_id = await _find_asset_id(Costume, q)
|
||||
exists = costume_id is not None
|
||||
costumes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": exists,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": costume_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
if effective_shot_id:
|
||||
char_ids = {r["asset_id"] for r in characters_out if r.get("asset_id")}
|
||||
linked_char_ids: set[str] = set()
|
||||
if char_ids:
|
||||
stmt = select(ShotCharacterLink.character_id).where(
|
||||
ShotCharacterLink.shot_id == effective_shot_id,
|
||||
ShotCharacterLink.character_id.in_(char_ids),
|
||||
)
|
||||
linked_char_ids = {row[0] for row in (await self._db.execute(stmt)).all()}
|
||||
for r in characters_out:
|
||||
aid = r.get("asset_id")
|
||||
if aid and aid in linked_char_ids:
|
||||
r["linked_to_shot"] = True
|
||||
|
||||
prop_ids = {r["asset_id"] for r in props_out if r.get("asset_id")}
|
||||
linked_prop_ids: set[str] = set()
|
||||
if prop_ids:
|
||||
stmt = select(ProjectPropLink.prop_id).where(
|
||||
ProjectPropLink.project_id == project_id,
|
||||
ProjectPropLink.shot_id == effective_shot_id,
|
||||
ProjectPropLink.prop_id.in_(prop_ids),
|
||||
)
|
||||
linked_prop_ids = {row[0] for row in (await self._db.execute(stmt)).all()}
|
||||
for r in props_out:
|
||||
aid = r.get("asset_id")
|
||||
if aid and aid in linked_prop_ids:
|
||||
r["linked_to_shot"] = True
|
||||
|
||||
scene_ids = {r["asset_id"] for r in scenes_out if r.get("asset_id")}
|
||||
linked_scene_ids: set[str] = set()
|
||||
if scene_ids:
|
||||
stmt = select(ProjectSceneLink.scene_id).where(
|
||||
ProjectSceneLink.project_id == project_id,
|
||||
ProjectSceneLink.shot_id == effective_shot_id,
|
||||
ProjectSceneLink.scene_id.in_(scene_ids),
|
||||
)
|
||||
linked_scene_ids = {row[0] for row in (await self._db.execute(stmt)).all()}
|
||||
for r in scenes_out:
|
||||
aid = r.get("asset_id")
|
||||
if aid and aid in linked_scene_ids:
|
||||
r["linked_to_shot"] = True
|
||||
|
||||
costume_ids = {r["asset_id"] for r in costumes_out if r.get("asset_id")}
|
||||
linked_costume_ids: set[str] = set()
|
||||
if costume_ids:
|
||||
stmt = select(ProjectCostumeLink.costume_id).where(
|
||||
ProjectCostumeLink.project_id == project_id,
|
||||
ProjectCostumeLink.shot_id == effective_shot_id,
|
||||
ProjectCostumeLink.costume_id.in_(costume_ids),
|
||||
)
|
||||
linked_costume_ids = {row[0] for row in (await self._db.execute(stmt)).all()}
|
||||
for r in costumes_out:
|
||||
aid = r.get("asset_id")
|
||||
if aid and aid in linked_costume_ids:
|
||||
r["linked_to_shot"] = True
|
||||
|
||||
return {
|
||||
"characters": characters_out,
|
||||
"props": props_out,
|
||||
"scenes": scenes_out,
|
||||
"costumes": costumes_out,
|
||||
}
|
||||
|
||||
async def _resolve_thumbnails(self, *, image_model: type, parent_field_name: str, parent_ids: list[str]) -> dict[str, str]:
|
||||
return await resolve_thumbnails(
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
return await check_names_existence_service(
|
||||
self._db,
|
||||
image_model=image_model,
|
||||
parent_field_name=parent_field_name,
|
||||
parent_ids=parent_ids,
|
||||
project_id=project_id,
|
||||
shot_id=shot_id,
|
||||
character_names=character_names,
|
||||
prop_names=prop_names,
|
||||
scene_names=scene_names,
|
||||
costume_names=costume_names,
|
||||
)
|
||||
|
||||
def _asset_read_payload(self, obj: Any, thumbnail: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"description": obj.description,
|
||||
"tags": obj.tags or [],
|
||||
"prompt_template_id": obj.prompt_template_id,
|
||||
"view_count": obj.view_count,
|
||||
"style": obj.style,
|
||||
"visual_style": obj.visual_style,
|
||||
"thumbnail": thumbnail,
|
||||
}
|
||||
|
||||
async def list_entities(
|
||||
self,
|
||||
*,
|
||||
@@ -601,145 +57,41 @@ class StudioEntitiesService:
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(t)
|
||||
stmt = select(spec.model)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[spec.model.name, spec.model.description])
|
||||
if style:
|
||||
stmt = stmt.where(getattr(spec.model, "style") == style)
|
||||
if visual_style:
|
||||
stmt = stmt.where(getattr(spec.model, "visual_style") == visual_style)
|
||||
stmt = apply_order(stmt, model=spec.model, order=order, is_desc=is_desc, allow_fields=ENTITY_ORDER_FIELDS, default="created_at")
|
||||
items, total = await paginate(self._db, stmt=stmt, page=page, page_size=page_size)
|
||||
|
||||
thumbnails = await self._resolve_thumbnails(
|
||||
image_model=spec.image_model,
|
||||
parent_field_name=spec.id_field,
|
||||
parent_ids=[x.id for x in items],
|
||||
) -> tuple[list[dict[str, object]], int]:
|
||||
return await list_entities_paginated(
|
||||
self._db,
|
||||
entity_type=entity_type,
|
||||
q=q,
|
||||
style=style,
|
||||
visual_style=visual_style,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
payload: list[dict[str, Any]] = []
|
||||
for x in items:
|
||||
thumbnail = thumbnails.get(x.id, "")
|
||||
if t in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
payload.append(read_model.model_validate(x).model_copy(update={"thumbnail": thumbnail}).model_dump())
|
||||
else:
|
||||
payload.append(self._asset_read_payload(x, thumbnail))
|
||||
return payload, total
|
||||
|
||||
async def create_entity(self, *, entity_type: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(t)
|
||||
parsed = spec.create_model.model_validate(body)
|
||||
data = parsed.model_dump()
|
||||
async def create_entity(self, *, entity_type: str, body: dict[str, object]) -> dict[str, object]:
|
||||
return await create_entity_service(self._db, entity_type=entity_type, body=body)
|
||||
|
||||
link_project_id: str | None = None
|
||||
link_chapter_id: str | None = None
|
||||
link_shot_id: str | None = None
|
||||
if t in _LINK_MODEL_BY_ENTITY:
|
||||
link_project_id = data.pop("project_id", None)
|
||||
link_chapter_id = data.pop("chapter_id", None)
|
||||
link_shot_id = data.pop("shot_id", None)
|
||||
async def get_entity(self, *, entity_type: str, entity_id: str) -> dict[str, object]:
|
||||
return await get_entity_service(self._db, entity_type=entity_type, entity_id=entity_id)
|
||||
|
||||
exists = await self._db.get(spec.model, data["id"])
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail=f"{spec.model.__name__} with id={data['id']} already exists")
|
||||
|
||||
if t == "character":
|
||||
if await self._db.get(Project, data["project_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail="Project not found")
|
||||
if data.get("actor_id"):
|
||||
if await self._db.get(Actor, data["actor_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail="Actor not found")
|
||||
if data.get("costume_id") and await self._db.get(Costume, data["costume_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail="Costume not found")
|
||||
|
||||
obj = spec.model(**data)
|
||||
self._db.add(obj)
|
||||
await self._db.flush()
|
||||
await self._db.refresh(obj)
|
||||
|
||||
if t in {"actor", "scene", "prop", "costume"}:
|
||||
count = int(getattr(obj, "view_count", 1) or 1)
|
||||
angles = list(DEFAULT_VIEW_ANGLES[: min(max(count, 0), len(DEFAULT_VIEW_ANGLES))])
|
||||
for angle in angles:
|
||||
self._db.add(spec.image_model(**{spec.id_field: obj.id, "view_angle": angle}))
|
||||
if angles:
|
||||
await self._db.flush()
|
||||
|
||||
if link_project_id is not None and t in _LINK_MODEL_BY_ENTITY:
|
||||
link_model, asset_field = _LINK_MODEL_BY_ENTITY[t]
|
||||
await upsert_project_link(
|
||||
self._db,
|
||||
model=link_model,
|
||||
asset_field=asset_field, # type: ignore[arg-type]
|
||||
asset_id=obj.id,
|
||||
project_id=link_project_id,
|
||||
chapter_id=link_chapter_id,
|
||||
shot_id=link_shot_id,
|
||||
)
|
||||
|
||||
if t in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
payload = read_model.model_validate(obj).model_dump()
|
||||
payload["thumbnail"] = ""
|
||||
return payload
|
||||
return self._asset_read_payload(obj, "")
|
||||
|
||||
async def get_entity(self, *, entity_type: str, entity_id: str) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(t)
|
||||
obj = await self._db.get(spec.model, entity_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"{spec.model.__name__} not found")
|
||||
|
||||
thumbnails = await self._resolve_thumbnails(
|
||||
image_model=spec.image_model,
|
||||
parent_field_name=spec.id_field,
|
||||
parent_ids=[entity_id],
|
||||
async def update_entity(
|
||||
self,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
body: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
return await update_entity_service(
|
||||
self._db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
body=body,
|
||||
)
|
||||
thumbnail = thumbnails.get(entity_id, "")
|
||||
if t in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
return read_model.model_validate(obj).model_copy(update={"thumbnail": thumbnail}).model_dump()
|
||||
return self._asset_read_payload(obj, thumbnail)
|
||||
|
||||
async def update_entity(self, *, entity_type: str, entity_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(t)
|
||||
obj = await self._db.get(spec.model, entity_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"{spec.model.__name__} not found")
|
||||
|
||||
update_data = spec.update_model.model_validate(body).model_dump(exclude_unset=True)
|
||||
if t == "character":
|
||||
if "project_id" in update_data and await self._db.get(Project, update_data["project_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail="Project not found")
|
||||
if "actor_id" in update_data and update_data["actor_id"] is not None and await self._db.get(Actor, update_data["actor_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail="Actor not found")
|
||||
if "costume_id" in update_data and update_data["costume_id"] is not None and await self._db.get(Costume, update_data["costume_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail="Costume not found")
|
||||
|
||||
for k, v in update_data.items():
|
||||
setattr(obj, k, v)
|
||||
await self._db.flush()
|
||||
await self._db.refresh(obj)
|
||||
|
||||
if t in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
payload = read_model.model_validate(obj).model_dump()
|
||||
payload["thumbnail"] = ""
|
||||
return payload
|
||||
return self._asset_read_payload(obj, "")
|
||||
|
||||
async def delete_entity(self, *, entity_type: str, entity_id: str) -> None:
|
||||
spec = entity_spec(entity_type)
|
||||
obj = await self._db.get(spec.model, entity_id)
|
||||
if obj is None:
|
||||
return
|
||||
await self._db.delete(obj)
|
||||
await self._db.flush()
|
||||
await delete_entity_service(self._db, entity_type=entity_type, entity_id=entity_id)
|
||||
|
||||
async def list_entity_images(
|
||||
self,
|
||||
@@ -750,49 +102,30 @@ class StudioEntitiesService:
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
spec = entity_spec(entity_type)
|
||||
parent = await self._db.get(spec.model, entity_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=404, detail=f"{spec.model.__name__} not found")
|
||||
|
||||
id_field = getattr(spec.image_model, spec.id_field)
|
||||
stmt = select(spec.image_model).where(id_field == entity_id)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=spec.image_model,
|
||||
) -> tuple[list[dict[str, object]], int]:
|
||||
return await list_entity_images_paginated(
|
||||
self._db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=IMAGE_ORDER_FIELDS,
|
||||
default="id",
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
items, total = await paginate(self._db, stmt=stmt, page=page, page_size=page_size)
|
||||
payload = [spec.image_read_model.model_validate(x).model_dump() for x in items]
|
||||
return payload, total
|
||||
|
||||
async def create_entity_image(self, *, entity_type: str, entity_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(t)
|
||||
parent = await self._db.get(spec.model, entity_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=404, detail=f"{spec.model.__name__} not found")
|
||||
|
||||
parsed = spec.image_create_model.model_validate(body).model_dump()
|
||||
obj = spec.image_model(**{spec.id_field: entity_id, **parsed})
|
||||
self._db.add(obj)
|
||||
await self._db.flush()
|
||||
await self._db.refresh(obj)
|
||||
|
||||
if t == "character" and getattr(obj, "is_primary", False):
|
||||
stmt = (
|
||||
CharacterImage.__table__.update()
|
||||
.where(CharacterImage.character_id == entity_id, CharacterImage.id != obj.id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
await self._db.execute(stmt)
|
||||
await self._db.refresh(obj)
|
||||
|
||||
return spec.image_read_model.model_validate(obj).model_dump()
|
||||
async def create_entity_image(
|
||||
self,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
body: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
return await create_entity_image_service(
|
||||
self._db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
body=body,
|
||||
)
|
||||
|
||||
async def update_entity_image(
|
||||
self,
|
||||
@@ -800,35 +133,20 @@ class StudioEntitiesService:
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
image_id: int,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(t)
|
||||
obj = await self._db.get(spec.image_model, image_id)
|
||||
if obj is None or getattr(obj, spec.id_field) != entity_id:
|
||||
raise HTTPException(status_code=404, detail=f"{spec.image_model.__name__} not found")
|
||||
|
||||
update_data = spec.image_update_model.model_validate(body).model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(obj, k, v)
|
||||
await self._db.flush()
|
||||
await self._db.refresh(obj)
|
||||
|
||||
if t == "character" and update_data.get("is_primary") is True:
|
||||
stmt = (
|
||||
CharacterImage.__table__.update()
|
||||
.where(CharacterImage.character_id == entity_id, CharacterImage.id != obj.id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
await self._db.execute(stmt)
|
||||
await self._db.refresh(obj)
|
||||
|
||||
return spec.image_read_model.model_validate(obj).model_dump()
|
||||
body: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
return await update_entity_image_service(
|
||||
self._db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
image_id=image_id,
|
||||
body=body,
|
||||
)
|
||||
|
||||
async def delete_entity_image(self, *, entity_type: str, entity_id: str, image_id: int) -> None:
|
||||
spec = entity_spec(entity_type)
|
||||
obj = await self._db.get(spec.image_model, image_id)
|
||||
if obj is None or getattr(obj, spec.id_field) != entity_id:
|
||||
return
|
||||
await self._db.delete(obj)
|
||||
await self._db.flush()
|
||||
await delete_entity_image_service(
|
||||
self._db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
image_id=image_id,
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Studio 实体主资源 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.models.studio import Actor, Costume, Project
|
||||
from app.services.common import entity_already_exists, entity_not_found
|
||||
from app.services.studio.entity_specs import DEFAULT_VIEW_ANGLES, LINK_MODEL_BY_ENTITY, entity_spec, normalize_entity_type
|
||||
from app.services.studio.entity_thumbnails import resolve_thumbnails
|
||||
from app.utils.project_links import upsert_project_link
|
||||
|
||||
ENTITY_ORDER_FIELDS = {"name", "style", "visual_style", "created_at", "updated_at"}
|
||||
|
||||
|
||||
def _asset_read_payload(obj: Any, thumbnail: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"description": obj.description,
|
||||
"tags": obj.tags or [],
|
||||
"prompt_template_id": obj.prompt_template_id,
|
||||
"view_count": obj.view_count,
|
||||
"style": obj.style,
|
||||
"visual_style": obj.visual_style,
|
||||
"thumbnail": thumbnail,
|
||||
}
|
||||
|
||||
|
||||
async def list_entities_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
q: str | None,
|
||||
style: str | None,
|
||||
visual_style: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(entity_type_norm)
|
||||
stmt = select(spec.model)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[spec.model.name, spec.model.description])
|
||||
if style:
|
||||
stmt = stmt.where(getattr(spec.model, "style") == style)
|
||||
if visual_style:
|
||||
stmt = stmt.where(getattr(spec.model, "visual_style") == visual_style)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=spec.model,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=ENTITY_ORDER_FIELDS,
|
||||
default="created_at",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
|
||||
thumbnails = await resolve_thumbnails(
|
||||
db,
|
||||
image_model=spec.image_model,
|
||||
parent_field_name=spec.id_field,
|
||||
parent_ids=[item.id for item in items],
|
||||
)
|
||||
payload: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
thumbnail = thumbnails.get(item.id, "")
|
||||
if entity_type_norm in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
payload.append(read_model.model_validate(item).model_copy(update={"thumbnail": thumbnail}).model_dump())
|
||||
else:
|
||||
payload.append(_asset_read_payload(item, thumbnail))
|
||||
return payload, total
|
||||
|
||||
|
||||
async def create_entity(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(entity_type_norm)
|
||||
parsed = spec.create_model.model_validate(body)
|
||||
data = parsed.model_dump()
|
||||
|
||||
link_project_id: str | None = None
|
||||
link_chapter_id: str | None = None
|
||||
link_shot_id: str | None = None
|
||||
if entity_type_norm in LINK_MODEL_BY_ENTITY:
|
||||
link_project_id = data.pop("project_id", None)
|
||||
link_chapter_id = data.pop("chapter_id", None)
|
||||
link_shot_id = data.pop("shot_id", None)
|
||||
|
||||
exists = await db.get(spec.model, data["id"])
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=400, detail=entity_already_exists(spec.model.__name__))
|
||||
|
||||
if entity_type_norm == "character":
|
||||
if await db.get(Project, data["project_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail=entity_not_found("Project"))
|
||||
if data.get("actor_id") and await db.get(Actor, data["actor_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail=entity_not_found("Actor"))
|
||||
if data.get("costume_id") and await db.get(Costume, data["costume_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail=entity_not_found("Costume"))
|
||||
|
||||
obj = spec.model(**data)
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
|
||||
if entity_type_norm in {"actor", "scene", "prop", "costume"}:
|
||||
count = int(getattr(obj, "view_count", 1) or 1)
|
||||
angles = list(DEFAULT_VIEW_ANGLES[: min(max(count, 0), len(DEFAULT_VIEW_ANGLES))])
|
||||
for angle in angles:
|
||||
db.add(spec.image_model(**{spec.id_field: obj.id, "view_angle": angle}))
|
||||
if angles:
|
||||
await db.flush()
|
||||
|
||||
if link_project_id is not None and entity_type_norm in LINK_MODEL_BY_ENTITY:
|
||||
link_model, asset_field = LINK_MODEL_BY_ENTITY[entity_type_norm]
|
||||
await upsert_project_link(
|
||||
db,
|
||||
model=link_model,
|
||||
asset_field=asset_field, # type: ignore[arg-type]
|
||||
asset_id=obj.id,
|
||||
project_id=link_project_id,
|
||||
chapter_id=link_chapter_id,
|
||||
shot_id=link_shot_id,
|
||||
)
|
||||
|
||||
if entity_type_norm in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
payload = read_model.model_validate(obj).model_dump()
|
||||
payload["thumbnail"] = ""
|
||||
return payload
|
||||
return _asset_read_payload(obj, "")
|
||||
|
||||
|
||||
async def get_entity(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
) -> dict[str, Any]:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(entity_type_norm)
|
||||
obj = await db.get(spec.model, entity_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found(spec.model.__name__))
|
||||
|
||||
thumbnails = await resolve_thumbnails(
|
||||
db,
|
||||
image_model=spec.image_model,
|
||||
parent_field_name=spec.id_field,
|
||||
parent_ids=[entity_id],
|
||||
)
|
||||
thumbnail = thumbnails.get(entity_id, "")
|
||||
if entity_type_norm in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
return read_model.model_validate(obj).model_copy(update={"thumbnail": thumbnail}).model_dump()
|
||||
return _asset_read_payload(obj, thumbnail)
|
||||
|
||||
|
||||
async def update_entity(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(entity_type_norm)
|
||||
obj = await db.get(spec.model, entity_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found(spec.model.__name__))
|
||||
|
||||
update_data = spec.update_model.model_validate(body).model_dump(exclude_unset=True)
|
||||
if entity_type_norm == "character":
|
||||
if "project_id" in update_data and await db.get(Project, update_data["project_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail=entity_not_found("Project"))
|
||||
if "actor_id" in update_data and update_data["actor_id"] is not None and await db.get(Actor, update_data["actor_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail=entity_not_found("Actor"))
|
||||
if "costume_id" in update_data and update_data["costume_id"] is not None and await db.get(Costume, update_data["costume_id"]) is None:
|
||||
raise HTTPException(status_code=400, detail=entity_not_found("Costume"))
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(obj, key, value)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
|
||||
if entity_type_norm in {"actor", "character"}:
|
||||
read_model = spec.read_model
|
||||
payload = read_model.model_validate(obj).model_dump()
|
||||
payload["thumbnail"] = ""
|
||||
return payload
|
||||
return _asset_read_payload(obj, "")
|
||||
|
||||
|
||||
async def delete_entity(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
spec = entity_spec(entity_type)
|
||||
obj = await db.get(spec.model, entity_id)
|
||||
if obj is None:
|
||||
return
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""实体名称存在性检测服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import (
|
||||
Chapter,
|
||||
Character,
|
||||
Costume,
|
||||
ProjectCostumeLink,
|
||||
ProjectPropLink,
|
||||
ProjectSceneLink,
|
||||
Prop,
|
||||
Scene,
|
||||
Shot,
|
||||
ShotCharacterLink,
|
||||
)
|
||||
from app.services.common import relation_mismatch
|
||||
|
||||
|
||||
async def check_names_existence(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_id: str,
|
||||
shot_id: str | None = None,
|
||||
character_names: list[str],
|
||||
prop_names: list[str],
|
||||
scene_names: list[str],
|
||||
costume_names: list[str],
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""批量检测名称是否存在,并返回项目/镜头关联状态。"""
|
||||
|
||||
effective_shot_id = shot_id.strip() if shot_id and str(shot_id).strip() else None
|
||||
if effective_shot_id:
|
||||
shot_ok = (
|
||||
await db.execute(
|
||||
select(Shot.id)
|
||||
.join(Chapter, Shot.chapter_id == Chapter.id)
|
||||
.where(Shot.id == effective_shot_id, Chapter.project_id == project_id)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if shot_ok is None:
|
||||
raise HTTPException(status_code=404, detail=relation_mismatch("shot_id", "project_id"))
|
||||
|
||||
async def _find_character_id(q: str) -> str | None:
|
||||
stmt = (
|
||||
select(Character.id)
|
||||
.where(Character.project_id == project_id, Character.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
return str(row) if row is not None else None
|
||||
|
||||
async def _find_asset_id(model: type, q: str) -> str | None:
|
||||
stmt = select(getattr(model, "id")).where(getattr(model, "name").ilike(f"%{q}%")).limit(1)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
return str(row) if row is not None else None
|
||||
|
||||
async def _find_linked_prop(q: str) -> tuple[int, str] | None:
|
||||
stmt = (
|
||||
select(ProjectPropLink.id, Prop.id)
|
||||
.join(Prop, Prop.id == ProjectPropLink.prop_id)
|
||||
.where(ProjectPropLink.project_id == project_id, Prop.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await db.execute(stmt)).first()
|
||||
if not row:
|
||||
return None
|
||||
link_id, prop_id = row
|
||||
return int(link_id), str(prop_id)
|
||||
|
||||
async def _find_linked_scene(q: str) -> tuple[int, str] | None:
|
||||
stmt = (
|
||||
select(ProjectSceneLink.id, Scene.id)
|
||||
.join(Scene, Scene.id == ProjectSceneLink.scene_id)
|
||||
.where(ProjectSceneLink.project_id == project_id, Scene.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await db.execute(stmt)).first()
|
||||
if not row:
|
||||
return None
|
||||
link_id, scene_id = row
|
||||
return int(link_id), str(scene_id)
|
||||
|
||||
async def _find_linked_costume(q: str) -> tuple[int, str] | None:
|
||||
stmt = (
|
||||
select(ProjectCostumeLink.id, Costume.id)
|
||||
.join(Costume, Costume.id == ProjectCostumeLink.costume_id)
|
||||
.where(ProjectCostumeLink.project_id == project_id, Costume.name.ilike(f"%{q}%"))
|
||||
.limit(1)
|
||||
)
|
||||
row = (await db.execute(stmt)).first()
|
||||
if not row:
|
||||
return None
|
||||
link_id, costume_id = row
|
||||
return int(link_id), str(costume_id)
|
||||
|
||||
def _empty_item(raw: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": raw,
|
||||
"exists": False,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": None,
|
||||
"link_id": None,
|
||||
}
|
||||
|
||||
characters_out: list[dict[str, Any]] = []
|
||||
for name in character_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
characters_out.append(_empty_item(raw))
|
||||
continue
|
||||
char_id = await _find_character_id(q)
|
||||
exists = char_id is not None
|
||||
characters_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": exists,
|
||||
"linked_to_project": exists,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": char_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
props_out: list[dict[str, Any]] = []
|
||||
for name in prop_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
props_out.append(_empty_item(raw))
|
||||
continue
|
||||
linked_row = await _find_linked_prop(q)
|
||||
if linked_row is not None:
|
||||
link_id, prop_id = linked_row
|
||||
props_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": prop_id,
|
||||
"link_id": link_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
prop_id = await _find_asset_id(Prop, q)
|
||||
props_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": prop_id is not None,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": prop_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
scenes_out: list[dict[str, Any]] = []
|
||||
for name in scene_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
scenes_out.append(_empty_item(raw))
|
||||
continue
|
||||
linked_row = await _find_linked_scene(q)
|
||||
if linked_row is not None:
|
||||
link_id, scene_id = linked_row
|
||||
scenes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": scene_id,
|
||||
"link_id": link_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
scene_id = await _find_asset_id(Scene, q)
|
||||
scenes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": scene_id is not None,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": scene_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
costumes_out: list[dict[str, Any]] = []
|
||||
for name in costume_names or []:
|
||||
raw = str(name)
|
||||
q = raw.strip()
|
||||
if not q:
|
||||
costumes_out.append(_empty_item(raw))
|
||||
continue
|
||||
linked_row = await _find_linked_costume(q)
|
||||
if linked_row is not None:
|
||||
link_id, costume_id = linked_row
|
||||
costumes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": costume_id,
|
||||
"link_id": link_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
costume_id = await _find_asset_id(Costume, q)
|
||||
costumes_out.append(
|
||||
{
|
||||
"name": raw,
|
||||
"exists": costume_id is not None,
|
||||
"linked_to_project": False,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": costume_id,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
if effective_shot_id:
|
||||
char_ids = {r["asset_id"] for r in characters_out if r.get("asset_id")}
|
||||
if char_ids:
|
||||
stmt = select(ShotCharacterLink.character_id).where(
|
||||
ShotCharacterLink.shot_id == effective_shot_id,
|
||||
ShotCharacterLink.character_id.in_(char_ids),
|
||||
)
|
||||
linked_char_ids = {row[0] for row in (await db.execute(stmt)).all()}
|
||||
for row in characters_out:
|
||||
aid = row.get("asset_id")
|
||||
if aid and aid in linked_char_ids:
|
||||
row["linked_to_shot"] = True
|
||||
|
||||
prop_ids = {r["asset_id"] for r in props_out if r.get("asset_id")}
|
||||
if prop_ids:
|
||||
stmt = select(ProjectPropLink.prop_id).where(
|
||||
ProjectPropLink.project_id == project_id,
|
||||
ProjectPropLink.shot_id == effective_shot_id,
|
||||
ProjectPropLink.prop_id.in_(prop_ids),
|
||||
)
|
||||
linked_prop_ids = {row[0] for row in (await db.execute(stmt)).all()}
|
||||
for row in props_out:
|
||||
aid = row.get("asset_id")
|
||||
if aid and aid in linked_prop_ids:
|
||||
row["linked_to_shot"] = True
|
||||
|
||||
scene_ids = {r["asset_id"] for r in scenes_out if r.get("asset_id")}
|
||||
if scene_ids:
|
||||
stmt = select(ProjectSceneLink.scene_id).where(
|
||||
ProjectSceneLink.project_id == project_id,
|
||||
ProjectSceneLink.shot_id == effective_shot_id,
|
||||
ProjectSceneLink.scene_id.in_(scene_ids),
|
||||
)
|
||||
linked_scene_ids = {row[0] for row in (await db.execute(stmt)).all()}
|
||||
for row in scenes_out:
|
||||
aid = row.get("asset_id")
|
||||
if aid and aid in linked_scene_ids:
|
||||
row["linked_to_shot"] = True
|
||||
|
||||
costume_ids = {r["asset_id"] for r in costumes_out if r.get("asset_id")}
|
||||
if costume_ids:
|
||||
stmt = select(ProjectCostumeLink.costume_id).where(
|
||||
ProjectCostumeLink.project_id == project_id,
|
||||
ProjectCostumeLink.shot_id == effective_shot_id,
|
||||
ProjectCostumeLink.costume_id.in_(costume_ids),
|
||||
)
|
||||
linked_costume_ids = {row[0] for row in (await db.execute(stmt)).all()}
|
||||
for row in costumes_out:
|
||||
aid = row.get("asset_id")
|
||||
if aid and aid in linked_costume_ids:
|
||||
row["linked_to_shot"] = True
|
||||
|
||||
return {
|
||||
"characters": characters_out,
|
||||
"props": props_out,
|
||||
"scenes": scenes_out,
|
||||
"costumes": costumes_out,
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Studio 实体图片 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_order, paginate
|
||||
from app.models.studio import CharacterImage
|
||||
from app.services.common import entity_not_found
|
||||
from app.services.studio.entity_specs import entity_spec, normalize_entity_type
|
||||
|
||||
IMAGE_ORDER_FIELDS = {"id", "quality_level", "view_angle", "created_at", "updated_at"}
|
||||
|
||||
|
||||
async def list_entity_images_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
spec = entity_spec(entity_type)
|
||||
parent = await db.get(spec.model, entity_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found(spec.model.__name__))
|
||||
|
||||
id_field = getattr(spec.image_model, spec.id_field)
|
||||
stmt = select(spec.image_model).where(id_field == entity_id)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=spec.image_model,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=IMAGE_ORDER_FIELDS,
|
||||
default="id",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
payload = [spec.image_read_model.model_validate(x).model_dump() for x in items]
|
||||
return payload, total
|
||||
|
||||
|
||||
async def create_entity_image(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(entity_type_norm)
|
||||
parent = await db.get(spec.model, entity_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found(spec.model.__name__))
|
||||
|
||||
parsed = spec.image_create_model.model_validate(body).model_dump()
|
||||
obj = spec.image_model(**{spec.id_field: entity_id, **parsed})
|
||||
db.add(obj)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
|
||||
if entity_type_norm == "character" and getattr(obj, "is_primary", False):
|
||||
stmt = (
|
||||
CharacterImage.__table__.update()
|
||||
.where(CharacterImage.character_id == entity_id, CharacterImage.id != obj.id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.refresh(obj)
|
||||
|
||||
return spec.image_read_model.model_validate(obj).model_dump()
|
||||
|
||||
|
||||
async def update_entity_image(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
image_id: int,
|
||||
body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
spec = entity_spec(entity_type_norm)
|
||||
obj = await db.get(spec.image_model, image_id)
|
||||
if obj is None or getattr(obj, spec.id_field) != entity_id:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found(spec.image_model.__name__))
|
||||
|
||||
update_data = spec.image_update_model.model_validate(body).model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(obj, key, value)
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
|
||||
if entity_type_norm == "character" and update_data.get("is_primary") is True:
|
||||
stmt = (
|
||||
CharacterImage.__table__.update()
|
||||
.where(CharacterImage.character_id == entity_id, CharacterImage.id != obj.id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.refresh(obj)
|
||||
|
||||
return spec.image_read_model.model_validate(obj).model_dump()
|
||||
|
||||
|
||||
async def delete_entity_image(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
image_id: int,
|
||||
) -> None:
|
||||
spec = entity_spec(entity_type)
|
||||
obj = await db.get(spec.image_model, image_id)
|
||||
if obj is None or getattr(obj, spec.id_field) != entity_id:
|
||||
return
|
||||
await db.delete(obj)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Studio 实体规格定义与类型归一化。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models.studio import (
|
||||
Actor,
|
||||
ActorImage,
|
||||
AssetViewAngle,
|
||||
Character,
|
||||
CharacterImage,
|
||||
Costume,
|
||||
CostumeImage,
|
||||
ProjectActorLink,
|
||||
ProjectCostumeLink,
|
||||
ProjectPropLink,
|
||||
ProjectSceneLink,
|
||||
Prop,
|
||||
PropImage,
|
||||
Scene,
|
||||
SceneImage,
|
||||
)
|
||||
from app.schemas.studio.assets import (
|
||||
AssetCreate,
|
||||
AssetImageCreate,
|
||||
AssetImageUpdate,
|
||||
AssetUpdate,
|
||||
CharacterImageRead,
|
||||
CostumeImageRead,
|
||||
PropImageRead,
|
||||
SceneImageRead,
|
||||
)
|
||||
from app.schemas.studio.cast import ActorCreate, ActorRead, ActorUpdate, CharacterCreate, CharacterRead, CharacterUpdate
|
||||
from app.schemas.studio.cast_images import ActorImageRead
|
||||
from app.services.common import invalid_choice
|
||||
|
||||
DEFAULT_VIEW_ANGLES: tuple[AssetViewAngle, ...] = (
|
||||
AssetViewAngle.front,
|
||||
AssetViewAngle.left,
|
||||
AssetViewAngle.right,
|
||||
AssetViewAngle.back,
|
||||
)
|
||||
|
||||
LINK_MODEL_BY_ENTITY: dict[str, tuple[type, str]] = {
|
||||
"actor": (ProjectActorLink, "actor_id"),
|
||||
"scene": (ProjectSceneLink, "scene_id"),
|
||||
"prop": (ProjectPropLink, "prop_id"),
|
||||
"costume": (ProjectCostumeLink, "costume_id"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntitySpec:
|
||||
model: type
|
||||
image_model: type
|
||||
id_field: str
|
||||
read_model: type | None
|
||||
create_model: type
|
||||
update_model: type
|
||||
image_read_model: type
|
||||
image_create_model: type
|
||||
image_update_model: type
|
||||
|
||||
|
||||
def normalize_entity_type(entity_type: str) -> str:
|
||||
value = entity_type.strip().lower()
|
||||
if value not in {"actor", "character", "scene", "prop", "costume"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=invalid_choice("entity_type", ["actor", "character", "scene", "prop", "costume"]),
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def entity_spec(entity_type: str) -> EntitySpec:
|
||||
entity_type_norm = normalize_entity_type(entity_type)
|
||||
if entity_type_norm == "actor":
|
||||
return EntitySpec(
|
||||
model=Actor,
|
||||
image_model=ActorImage,
|
||||
id_field="actor_id",
|
||||
read_model=ActorRead,
|
||||
create_model=ActorCreate,
|
||||
update_model=ActorUpdate,
|
||||
image_read_model=ActorImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
if entity_type_norm == "character":
|
||||
return EntitySpec(
|
||||
model=Character,
|
||||
image_model=CharacterImage,
|
||||
id_field="character_id",
|
||||
read_model=CharacterRead,
|
||||
create_model=CharacterCreate,
|
||||
update_model=CharacterUpdate,
|
||||
image_read_model=CharacterImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
if entity_type_norm == "scene":
|
||||
return EntitySpec(
|
||||
model=Scene,
|
||||
image_model=SceneImage,
|
||||
id_field="scene_id",
|
||||
read_model=None,
|
||||
create_model=AssetCreate,
|
||||
update_model=AssetUpdate,
|
||||
image_read_model=SceneImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
if entity_type_norm == "prop":
|
||||
return EntitySpec(
|
||||
model=Prop,
|
||||
image_model=PropImage,
|
||||
id_field="prop_id",
|
||||
read_model=None,
|
||||
create_model=AssetCreate,
|
||||
update_model=AssetUpdate,
|
||||
image_read_model=PropImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
return EntitySpec(
|
||||
model=Costume,
|
||||
image_model=CostumeImage,
|
||||
id_field="costume_id",
|
||||
read_model=None,
|
||||
create_model=AssetCreate,
|
||||
update_model=AssetUpdate,
|
||||
image_read_model=CostumeImageRead,
|
||||
image_create_model=AssetImageCreate,
|
||||
image_update_model=AssetImageUpdate,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Studio 实体缩略图解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import AssetViewAngle
|
||||
|
||||
DOWNLOAD_URL_TEMPLATE = "/api/v1/studio/files/{file_id}/download"
|
||||
|
||||
|
||||
def download_url(file_id: str) -> str:
|
||||
return DOWNLOAD_URL_TEMPLATE.format(file_id=file_id)
|
||||
|
||||
|
||||
async def resolve_thumbnails(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_model: type,
|
||||
parent_field_name: str,
|
||||
parent_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
if not parent_ids:
|
||||
return {}
|
||||
parent_field = getattr(image_model, parent_field_name)
|
||||
stmt = select(image_model).where(parent_field.in_(parent_ids), image_model.file_id.is_not(None))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
best: dict[str, tuple[int, int, int, str]] = {}
|
||||
for row in rows:
|
||||
file_id = row.file_id
|
||||
if not file_id:
|
||||
continue
|
||||
parent_id = getattr(row, parent_field_name)
|
||||
created_ts = int(row.created_at.timestamp()) if row.created_at else -1
|
||||
score = (1 if row.view_angle == AssetViewAngle.front else 0, created_ts, row.id)
|
||||
current = best.get(parent_id)
|
||||
if current is None or score > current[:3]:
|
||||
best[parent_id] = (*score, file_id)
|
||||
return {parent_id: download_url(score[3]) for parent_id, score in best.items()}
|
||||
|
||||
|
||||
async def resolve_thumbnail_infos(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_model: type,
|
||||
parent_field_name: str,
|
||||
parent_ids: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""解析每个 parent_id 的最佳缩略图信息(thumbnail + image_id)。"""
|
||||
if not parent_ids:
|
||||
return {}
|
||||
parent_field = getattr(image_model, parent_field_name)
|
||||
stmt = select(image_model).where(parent_field.in_(parent_ids), image_model.file_id.is_not(None))
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
best: dict[str, tuple[int, int, int, int, str]] = {}
|
||||
for row in rows:
|
||||
file_id = row.file_id
|
||||
if not file_id:
|
||||
continue
|
||||
parent_id = getattr(row, parent_field_name)
|
||||
created_ts = int(row.created_at.timestamp()) if row.created_at else -1
|
||||
image_id = int(row.id)
|
||||
score3 = (1 if row.view_angle == AssetViewAngle.front else 0, created_ts, image_id)
|
||||
current = best.get(parent_id)
|
||||
if current is None or score3 > current[:3]:
|
||||
best[parent_id] = (*score3, image_id, file_id)
|
||||
|
||||
return {
|
||||
parent_id: {
|
||||
"image_id": info[3],
|
||||
"file_id": info[4],
|
||||
"thumbnail": download_url(info[4]),
|
||||
}
|
||||
for parent_id, info in best.items()
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""文件服务:封装文件上传、下载、列表、详情、更新与删除。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.core import storage
|
||||
from app.models.studio import FileItem, FileType
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.studio import FileDetailRead, FileRead, FileUpdate, FileUsageRead, FileUsageWrite
|
||||
from app.services.common import create_and_refresh, entity_not_found, flush_and_refresh, get_or_404, patch_model
|
||||
from app.services.studio.file_usages import upsert_file_usage
|
||||
|
||||
FILE_ORDER_FIELDS = {"name", "created_at", "updated_at"}
|
||||
|
||||
|
||||
def _detect_file_type(filename: str) -> FileType:
|
||||
_, ext = os.path.splitext(filename.lower())
|
||||
if ext in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
|
||||
return FileType.image
|
||||
if ext in {".mp4", ".mov", ".mkv", ".avi", ".webm"}:
|
||||
return FileType.video
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {ext or '未知后缀'}")
|
||||
|
||||
|
||||
def _build_display_name(filename: str, name: str | None) -> str:
|
||||
if name:
|
||||
return name
|
||||
base, _ = os.path.splitext(filename)
|
||||
return base or filename
|
||||
|
||||
|
||||
def _resolve_download_media_type(filename: str) -> str:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext in {".jpg", ".jpeg"}:
|
||||
return "image/jpeg"
|
||||
if ext == ".png":
|
||||
return "image/png"
|
||||
if ext == ".webp":
|
||||
return "image/webp"
|
||||
if ext == ".gif":
|
||||
return "image/gif"
|
||||
if ext == ".mp4":
|
||||
return "video/mp4"
|
||||
if ext == ".mov":
|
||||
return "video/quicktime"
|
||||
if ext in {".mkv", ".avi", ".webm"}:
|
||||
return "video/" + ext.lstrip(".")
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
async def list_files_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
q: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> ApiResponse[PaginatedData[FileRead]]:
|
||||
"""分页查询文件。"""
|
||||
stmt = select(FileItem)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[FileItem.name])
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=FileItem,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=FILE_ORDER_FIELDS,
|
||||
default="created_at",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[FileRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def get_file_detail(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file_id: str,
|
||||
) -> FileDetailRead:
|
||||
"""获取文件详情。"""
|
||||
stmt = select(FileItem).options(selectinload(FileItem.usages)).where(FileItem.id == file_id)
|
||||
res = await db.execute(stmt)
|
||||
obj = res.scalars().first()
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("File"))
|
||||
usages = [FileUsageRead.model_validate(u) for u in (obj.usages or [])]
|
||||
base = FileRead.model_validate(obj)
|
||||
return FileDetailRead(**base.model_dump(), usages=usages)
|
||||
|
||||
|
||||
async def update_file_meta(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file_id: str,
|
||||
body: FileUpdate,
|
||||
) -> FileItem:
|
||||
"""更新文件元信息,并按需写入 usage。"""
|
||||
obj = await get_or_404(db, FileItem, file_id, detail=entity_not_found("File"))
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
usage_payload = data.pop("usage", None)
|
||||
patch_model(obj, data)
|
||||
if usage_payload is not None:
|
||||
u = FileUsageWrite.model_validate(usage_payload)
|
||||
await upsert_file_usage(
|
||||
db,
|
||||
file_id=file_id,
|
||||
project_id=u.project_id,
|
||||
chapter_id=u.chapter_id,
|
||||
shot_id=u.shot_id,
|
||||
usage_kind=u.usage_kind,
|
||||
source_ref=u.source_ref,
|
||||
)
|
||||
return await flush_and_refresh(db, obj)
|
||||
|
||||
|
||||
async def upload_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
name: str | None = None,
|
||||
project_id: str | None = None,
|
||||
chapter_id: str | None = None,
|
||||
shot_id: str | None = None,
|
||||
usage_kind: str | None = None,
|
||||
source_ref: str | None = None,
|
||||
) -> FileItem:
|
||||
"""上传文件到对象存储,并创建 FileItem 记录。"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="上传文件缺少文件名")
|
||||
|
||||
file_type = _detect_file_type(file.filename)
|
||||
display_name = _build_display_name(file.filename, name)
|
||||
content = await file.read()
|
||||
|
||||
key = f"files/{file.filename}"
|
||||
info = await storage.upload_file(
|
||||
key=key,
|
||||
data=content,
|
||||
content_type=file.content_type,
|
||||
extra_args={"ACL": "public-read"},
|
||||
)
|
||||
|
||||
file_item = await create_and_refresh(
|
||||
db,
|
||||
FileItem(
|
||||
id=str(uuid.uuid4()),
|
||||
type=file_type,
|
||||
name=display_name,
|
||||
thumbnail=info.url,
|
||||
tags=[],
|
||||
storage_key=key,
|
||||
),
|
||||
)
|
||||
|
||||
if project_id and usage_kind:
|
||||
await upsert_file_usage(
|
||||
db,
|
||||
file_id=file_item.id,
|
||||
project_id=project_id,
|
||||
chapter_id=chapter_id,
|
||||
shot_id=shot_id,
|
||||
usage_kind=usage_kind,
|
||||
source_ref=source_ref,
|
||||
)
|
||||
|
||||
return file_item
|
||||
|
||||
|
||||
async def build_download_response(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file_id: str,
|
||||
) -> StreamingResponse:
|
||||
"""根据 file_id 构建下载响应。"""
|
||||
file_item = await get_or_404(db, FileItem, file_id, detail=entity_not_found("File"))
|
||||
content = await storage.download_file(key=file_item.storage_key)
|
||||
|
||||
filename = Path(file_item.storage_key).name or "download"
|
||||
media_type = _resolve_download_media_type(filename)
|
||||
content_disposition = f"attachment; filename*=UTF-8''{quote(filename)}"
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": content_disposition},
|
||||
)
|
||||
|
||||
|
||||
async def get_storage_info(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""读取对象存储信息。"""
|
||||
file_item = await get_or_404(db, FileItem, file_id, detail=entity_not_found("File"))
|
||||
info = await storage.get_file_info(key=file_item.storage_key)
|
||||
return {
|
||||
"key": info.key,
|
||||
"url": info.url,
|
||||
"size": info.size,
|
||||
"content_type": info.content_type,
|
||||
"etag": info.etag,
|
||||
}
|
||||
|
||||
|
||||
async def delete_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file_id: str,
|
||||
) -> None:
|
||||
"""删除文件记录与对象存储中的内容;若记录不存在则静默返回。"""
|
||||
file_item = await db.get(FileItem, file_id)
|
||||
if file_item is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await storage.delete_file(key=file_item.storage_key)
|
||||
except Exception:
|
||||
# 存储删除失败不阻塞记录删除,保持当前接口语义。
|
||||
pass
|
||||
|
||||
await db.delete(file_item)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,418 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.studio import (
|
||||
Actor,
|
||||
ActorImage,
|
||||
AssetViewAngle,
|
||||
Character,
|
||||
CharacterImage,
|
||||
Chapter,
|
||||
Costume,
|
||||
CostumeImage,
|
||||
PromptCategory,
|
||||
Prop,
|
||||
PropImage,
|
||||
Scene,
|
||||
SceneImage,
|
||||
Shot,
|
||||
ShotCharacterLink,
|
||||
ShotDetail,
|
||||
ShotFrameType,
|
||||
)
|
||||
from app.services.studio.image_task_references import (
|
||||
pick_front_ref_file_id,
|
||||
pick_ordered_ref_file_ids,
|
||||
)
|
||||
from app.services.common import entity_not_found, invalid_choice, not_belong_to, required_field
|
||||
from app.services.studio.image_tasks import (
|
||||
asset_prompt_category,
|
||||
build_prompt_with_template,
|
||||
is_front_view,
|
||||
map_view_angle_for_prompt,
|
||||
shot_frame_prompt_category,
|
||||
)
|
||||
|
||||
|
||||
async def build_actor_prompt_and_refs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
actor_id: str,
|
||||
image_id: int | None,
|
||||
) -> tuple[str, list[str], ActorImage]:
|
||||
actor = await db.get(Actor, actor_id)
|
||||
if actor is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Actor"))
|
||||
if image_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=required_field("image_id", when="actor generation"),
|
||||
)
|
||||
image_row = await db.get(ActorImage, image_id)
|
||||
if image_row is None or image_row.actor_id != actor_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "actor_id"),
|
||||
)
|
||||
front_view = is_front_view(image_row.view_angle)
|
||||
category = asset_prompt_category(relation_type="actor_image", is_front_view=front_view)
|
||||
prompt = await build_prompt_with_template(
|
||||
db,
|
||||
category=category,
|
||||
variables={
|
||||
"name": actor.name,
|
||||
"description": actor.description,
|
||||
"tags": ", ".join(actor.tags or []),
|
||||
"visual_style": actor.visual_style.value if hasattr(actor.visual_style, "value") else str(actor.visual_style),
|
||||
"style": actor.style.value if hasattr(actor.style, "value") else str(actor.style),
|
||||
"view_angle": map_view_angle_for_prompt(image_row.view_angle),
|
||||
"quality_level": image_row.quality_level,
|
||||
"format": image_row.format,
|
||||
},
|
||||
fallback_prompt=actor.description,
|
||||
not_found_msg="Actor.description is empty",
|
||||
)
|
||||
if front_view:
|
||||
return prompt, [], image_row
|
||||
fid = await pick_front_ref_file_id(
|
||||
db,
|
||||
image_model=ActorImage,
|
||||
parent_field_name="actor_id",
|
||||
parent_id=actor_id,
|
||||
preferred_quality_level=image_row.quality_level,
|
||||
)
|
||||
return prompt, ([fid] if fid else []), image_row
|
||||
|
||||
|
||||
async def build_asset_prompt_and_refs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
asset_type: str,
|
||||
asset_id: str,
|
||||
image_id: int | None,
|
||||
) -> tuple[str, list[str], str]:
|
||||
if image_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=required_field("image_id", when="asset image generation"),
|
||||
)
|
||||
|
||||
asset_type_norm = asset_type.strip().lower()
|
||||
if asset_type_norm == "prop":
|
||||
asset = await db.get(Prop, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Prop"))
|
||||
image_row = await db.get(PropImage, image_id)
|
||||
if image_row is None or image_row.prop_id != asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "prop_id"),
|
||||
)
|
||||
relation_type = "prop_image"
|
||||
front_view = is_front_view(image_row.view_angle)
|
||||
category = asset_prompt_category(relation_type=relation_type, is_front_view=front_view)
|
||||
prompt = await build_prompt_with_template(
|
||||
db,
|
||||
category=category,
|
||||
variables={
|
||||
"name": asset.name,
|
||||
"description": asset.description,
|
||||
"tags": ", ".join(asset.tags or []),
|
||||
"visual_style": asset.visual_style.value if hasattr(asset.visual_style, "value") else str(asset.visual_style),
|
||||
"style": asset.style.value if hasattr(asset.style, "value") else str(asset.style),
|
||||
"view_angle": map_view_angle_for_prompt(image_row.view_angle),
|
||||
"quality_level": image_row.quality_level,
|
||||
"format": image_row.format,
|
||||
},
|
||||
fallback_prompt=asset.description,
|
||||
not_found_msg="Prop.description is empty",
|
||||
)
|
||||
if front_view:
|
||||
return prompt, [], relation_type
|
||||
fid = await pick_front_ref_file_id(
|
||||
db,
|
||||
image_model=PropImage,
|
||||
parent_field_name="prop_id",
|
||||
parent_id=asset_id,
|
||||
preferred_quality_level=image_row.quality_level,
|
||||
)
|
||||
return prompt, ([fid] if fid else []), relation_type
|
||||
if asset_type_norm == "scene":
|
||||
asset = await db.get(Scene, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Scene"))
|
||||
image_row = await db.get(SceneImage, image_id)
|
||||
if image_row is None or image_row.scene_id != asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "scene_id"),
|
||||
)
|
||||
relation_type = "scene_image"
|
||||
front_view = is_front_view(image_row.view_angle)
|
||||
category = asset_prompt_category(relation_type=relation_type, is_front_view=front_view)
|
||||
prompt = await build_prompt_with_template(
|
||||
db,
|
||||
category=category,
|
||||
variables={
|
||||
"name": asset.name,
|
||||
"description": asset.description,
|
||||
"tags": ", ".join(asset.tags or []),
|
||||
"visual_style": asset.visual_style.value if hasattr(asset.visual_style, "value") else str(asset.visual_style),
|
||||
"style": asset.style.value if hasattr(asset.style, "value") else str(asset.style),
|
||||
"view_angle": map_view_angle_for_prompt(image_row.view_angle),
|
||||
"quality_level": image_row.quality_level,
|
||||
"format": image_row.format,
|
||||
},
|
||||
fallback_prompt=asset.description,
|
||||
not_found_msg="Scene.description is empty",
|
||||
)
|
||||
if front_view:
|
||||
return prompt, [], relation_type
|
||||
fid = await pick_front_ref_file_id(
|
||||
db,
|
||||
image_model=SceneImage,
|
||||
parent_field_name="scene_id",
|
||||
parent_id=asset_id,
|
||||
preferred_quality_level=image_row.quality_level,
|
||||
)
|
||||
return prompt, ([fid] if fid else []), relation_type
|
||||
if asset_type_norm == "costume":
|
||||
asset = await db.get(Costume, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Costume"))
|
||||
image_row = await db.get(CostumeImage, image_id)
|
||||
if image_row is None or image_row.costume_id != asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "costume_id"),
|
||||
)
|
||||
relation_type = "costume_image"
|
||||
front_view = is_front_view(image_row.view_angle)
|
||||
category = asset_prompt_category(relation_type=relation_type, is_front_view=front_view)
|
||||
prompt = await build_prompt_with_template(
|
||||
db,
|
||||
category=category,
|
||||
variables={
|
||||
"name": asset.name,
|
||||
"description": asset.description,
|
||||
"tags": ", ".join(asset.tags or []),
|
||||
"visual_style": asset.visual_style.value if hasattr(asset.visual_style, "value") else str(asset.visual_style),
|
||||
"style": asset.style.value if hasattr(asset.style, "value") else str(asset.style),
|
||||
"view_angle": map_view_angle_for_prompt(image_row.view_angle),
|
||||
"quality_level": image_row.quality_level,
|
||||
"format": image_row.format,
|
||||
},
|
||||
fallback_prompt=asset.description,
|
||||
not_found_msg="Costume.description is empty",
|
||||
)
|
||||
if front_view:
|
||||
return prompt, [], relation_type
|
||||
fid = await pick_front_ref_file_id(
|
||||
db,
|
||||
image_model=CostumeImage,
|
||||
parent_field_name="costume_id",
|
||||
parent_id=asset_id,
|
||||
preferred_quality_level=image_row.quality_level,
|
||||
)
|
||||
return prompt, ([fid] if fid else []), relation_type
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=invalid_choice("asset_type", ["prop", "scene", "costume"]),
|
||||
)
|
||||
|
||||
|
||||
async def build_character_prompt_and_refs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
character_id: str,
|
||||
image_id: int | None,
|
||||
) -> tuple[str, list[str], CharacterImage]:
|
||||
character = await db.get(Character, character_id)
|
||||
if character is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Character"))
|
||||
if image_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=required_field("image_id", when="character image generation"),
|
||||
)
|
||||
image_row = await db.get(CharacterImage, image_id)
|
||||
if image_row is None or image_row.character_id != character_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "character_id"),
|
||||
)
|
||||
prompt = await build_prompt_with_template(
|
||||
db,
|
||||
category=PromptCategory.combined,
|
||||
variables={
|
||||
"name": character.name,
|
||||
"description": character.description,
|
||||
"visual_style": character.visual_style.value if hasattr(character.visual_style, "value") else str(character.visual_style),
|
||||
"style": character.style.value if hasattr(character.style, "value") else str(character.style),
|
||||
"view_angle": map_view_angle_for_prompt(image_row.view_angle),
|
||||
"quality_level": image_row.quality_level,
|
||||
"format": image_row.format,
|
||||
},
|
||||
fallback_prompt=character.description,
|
||||
not_found_msg="Character.description is empty",
|
||||
)
|
||||
actor: Actor | None = await db.get(Actor, character.actor_id) if character.actor_id else None
|
||||
costume: Costume | None = await db.get(Costume, character.costume_id) if character.costume_id else None
|
||||
|
||||
default_view_angles: tuple[AssetViewAngle, ...] = (
|
||||
AssetViewAngle.front,
|
||||
AssetViewAngle.left,
|
||||
AssetViewAngle.right,
|
||||
AssetViewAngle.back,
|
||||
)
|
||||
actor_refs: list[str] = []
|
||||
if actor is not None:
|
||||
actor_refs = await pick_ordered_ref_file_ids(
|
||||
db,
|
||||
image_model=ActorImage,
|
||||
parent_field_name="actor_id",
|
||||
parent_id=actor.id,
|
||||
view_angles=default_view_angles,
|
||||
)
|
||||
costume_refs: list[str] = []
|
||||
if costume is not None:
|
||||
costume_refs = await pick_ordered_ref_file_ids(
|
||||
db,
|
||||
image_model=CostumeImage,
|
||||
parent_field_name="costume_id",
|
||||
parent_id=costume.id,
|
||||
view_angles=default_view_angles,
|
||||
)
|
||||
return prompt, [*actor_refs, *costume_refs], image_row
|
||||
|
||||
|
||||
def _cn_num(n: int) -> str:
|
||||
ones = {0: "零", 1: "一", 2: "二", 3: "三", 4: "四", 5: "五", 6: "六", 7: "七", 8: "八", 9: "九"}
|
||||
if n <= 9:
|
||||
return ones[n]
|
||||
if n == 10:
|
||||
return "十"
|
||||
if n < 20:
|
||||
return f"十{ones[n - 10]}"
|
||||
tens = n // 10
|
||||
rem = n % 10
|
||||
if rem == 0:
|
||||
return f"{ones[tens]}十"
|
||||
return f"{ones[tens]}十{ones[rem]}"
|
||||
|
||||
|
||||
async def build_shot_frame_prompt_and_refs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
frame_type: ShotFrameType,
|
||||
) -> tuple[str, list[str], ShotDetail]:
|
||||
shot_stmt = (
|
||||
select(ShotDetail)
|
||||
.options(selectinload(ShotDetail.shot).selectinload(Shot.chapter).selectinload(Chapter.project))
|
||||
.where(ShotDetail.id == shot_id)
|
||||
)
|
||||
shot_detail = (await db.execute(shot_stmt)).scalar_one_or_none()
|
||||
if shot_detail is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("ShotDetail"))
|
||||
|
||||
project = getattr(getattr(getattr(shot_detail, "shot", None), "chapter", None), "project", None)
|
||||
visual_style_raw = getattr(project, "visual_style", None)
|
||||
visual_style = (
|
||||
visual_style_raw.value if visual_style_raw is not None and hasattr(visual_style_raw, "value") else str(visual_style_raw or "")
|
||||
)
|
||||
style_raw = getattr(project, "style", None)
|
||||
style = style_raw.value if style_raw is not None and hasattr(style_raw, "value") else str(style_raw or "")
|
||||
|
||||
if frame_type == ShotFrameType.first:
|
||||
raw_prompt = (shot_detail.first_frame_prompt or "").strip()
|
||||
elif frame_type == ShotFrameType.last:
|
||||
raw_prompt = (shot_detail.last_frame_prompt or "").strip()
|
||||
else:
|
||||
raw_prompt = (shot_detail.key_frame_prompt or "").strip()
|
||||
if not raw_prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"ShotDetail has no prompt for frame_type={frame_type}",
|
||||
)
|
||||
|
||||
role_links_stmt = (
|
||||
select(ShotCharacterLink)
|
||||
.options(selectinload(ShotCharacterLink.character))
|
||||
.where(ShotCharacterLink.shot_id == shot_id)
|
||||
.order_by(ShotCharacterLink.index.asc())
|
||||
)
|
||||
role_links = (await db.execute(role_links_stmt)).scalars().all()
|
||||
|
||||
role_names: list[str] = []
|
||||
role_image_ids: list[str] = []
|
||||
for link in role_links:
|
||||
character = link.character
|
||||
if character is None:
|
||||
continue
|
||||
fid = await pick_front_ref_file_id(
|
||||
db,
|
||||
image_model=CharacterImage,
|
||||
parent_field_name="character_id",
|
||||
parent_id=character.id,
|
||||
preferred_quality_level=None,
|
||||
)
|
||||
if fid is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"CharacterImage front ref not found for character_id={character.id}, name={character.name}",
|
||||
)
|
||||
role_names.append(character.name)
|
||||
role_image_ids.append(fid)
|
||||
|
||||
if role_links and not role_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No valid shot character image refs found",
|
||||
)
|
||||
if len(set(role_names)) != len(role_names):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Duplicate character names in shot character links; cannot map names to image order",
|
||||
)
|
||||
|
||||
name_to_token: dict[str, str] = {name: f"图{_cn_num(i + 1)}" for i, name in enumerate(role_names)}
|
||||
sorted_pairs = sorted(name_to_token.items(), key=lambda kv: len(kv[0]), reverse=True)
|
||||
|
||||
def _replace_names(text: str) -> str:
|
||||
out = text or ""
|
||||
for name, token in sorted_pairs:
|
||||
if name:
|
||||
out = out.replace(name, token)
|
||||
return out
|
||||
|
||||
replaced_first = _replace_names(shot_detail.first_frame_prompt or "")
|
||||
replaced_last = _replace_names(shot_detail.last_frame_prompt or "")
|
||||
replaced_key = _replace_names(shot_detail.key_frame_prompt or "")
|
||||
base_prompt = _replace_names(raw_prompt)
|
||||
prompt = await build_prompt_with_template(
|
||||
db,
|
||||
category=shot_frame_prompt_category(frame_type),
|
||||
variables={
|
||||
"description": shot_detail.description,
|
||||
"atmosphere": shot_detail.atmosphere,
|
||||
"mood_tags": ", ".join(shot_detail.mood_tags or []),
|
||||
"visual_style": visual_style,
|
||||
"style": style,
|
||||
"camera_shot": shot_detail.camera_shot,
|
||||
"angle": shot_detail.angle,
|
||||
"movement": shot_detail.movement,
|
||||
"frame_type": frame_type,
|
||||
"first_frame_prompt": replaced_first,
|
||||
"last_frame_prompt": replaced_last,
|
||||
"key_frame_prompt": replaced_key,
|
||||
"base_prompt": base_prompt,
|
||||
},
|
||||
fallback_prompt=base_prompt,
|
||||
not_found_msg=f"ShotDetail has no prompt for frame_type={frame_type}",
|
||||
)
|
||||
return prompt, role_image_ids, shot_detail
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import storage
|
||||
from app.models.studio import AssetViewAngle, FileItem
|
||||
from app.schemas.studio.shots import ShotLinkedAssetItem
|
||||
|
||||
|
||||
async def resolve_reference_file_ids_and_names_from_linked_items(
|
||||
db: AsyncSession, # noqa: ARG001
|
||||
*,
|
||||
items: list[ShotLinkedAssetItem],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""将关联资产条目解析为参考图 file_id 列表(顺序有效)。"""
|
||||
file_ids: list[str] = []
|
||||
names: list[str] = []
|
||||
for item in items or []:
|
||||
name = (item.name or "").strip()
|
||||
file_id = (item.file_id or "").strip()
|
||||
if not file_id:
|
||||
continue
|
||||
file_ids.append(str(file_id))
|
||||
names.append(name or (item.id or ""))
|
||||
return file_ids, names
|
||||
|
||||
|
||||
async def resolve_reference_image_refs_by_file_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file_ids: list[str],
|
||||
) -> list[dict[str, str]]:
|
||||
"""将 file_id 列表解析为图片参考(data url)。顺序与入参一致。"""
|
||||
out: list[dict[str, str]] = []
|
||||
for fid in file_ids or []:
|
||||
file_id = (fid or "").strip()
|
||||
if not file_id:
|
||||
continue
|
||||
file_obj = await db.get(FileItem, file_id)
|
||||
if file_obj is None or not file_obj.storage_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"FileItem not found or storage_key empty for file_id={file_id}",
|
||||
)
|
||||
try:
|
||||
content = await storage.download_file(key=file_obj.storage_key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to download file for file_id={file_id}: {exc}",
|
||||
) from exc
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Empty file content for file_id={file_id}",
|
||||
)
|
||||
|
||||
content_type: str | None = None
|
||||
try:
|
||||
info = await storage.get_file_info(key=file_obj.storage_key)
|
||||
content_type = (info.content_type or "").strip().lower() or None
|
||||
except Exception: # noqa: BLE001
|
||||
content_type = None
|
||||
if not content_type:
|
||||
guessed_type, _ = mimetypes.guess_type(file_obj.storage_key)
|
||||
content_type = (guessed_type or "").strip().lower() or None
|
||||
if not content_type or not content_type.startswith("image/"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File is not an image for file_id={file_id}",
|
||||
)
|
||||
|
||||
image_format = content_type.split("/", 1)[1].split(";", 1)[0].strip().lower() or "png"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
out.append({"image_url": f"data:image/{image_format};base64,{encoded}"})
|
||||
return out
|
||||
|
||||
|
||||
async def pick_front_ref_file_id(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_model: type,
|
||||
parent_field_name: str,
|
||||
parent_id: str,
|
||||
preferred_quality_level: object | None,
|
||||
) -> str | None:
|
||||
"""按旧语义挑选 front 参考图的 file_id(不下载文件)。"""
|
||||
parent_field = getattr(image_model, parent_field_name)
|
||||
stmt = (
|
||||
select(image_model)
|
||||
.where(
|
||||
parent_field == parent_id,
|
||||
image_model.view_angle == AssetViewAngle.front,
|
||||
image_model.file_id.is_not(None),
|
||||
)
|
||||
.order_by(image_model.created_at.desc(), image_model.id.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
target = rows[0]
|
||||
if preferred_quality_level is not None:
|
||||
for row in rows:
|
||||
if getattr(row, "quality_level", None) == preferred_quality_level:
|
||||
target = row
|
||||
break
|
||||
|
||||
fid = getattr(target, "file_id", None)
|
||||
return str(fid) if fid else None
|
||||
|
||||
|
||||
async def pick_ordered_ref_file_ids(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_model: type,
|
||||
parent_field_name: str,
|
||||
parent_id: str,
|
||||
view_angles: tuple[AssetViewAngle, ...],
|
||||
) -> list[str]:
|
||||
"""按旧语义按角度顺序挑选参考图 file_id(不下载文件)。"""
|
||||
parent_field = getattr(image_model, parent_field_name)
|
||||
stmt = (
|
||||
select(image_model)
|
||||
.where(parent_field == parent_id, image_model.file_id.is_not(None))
|
||||
.order_by(image_model.created_at.desc(), image_model.id.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
best_by_angle: dict[str, object] = {}
|
||||
for row in rows:
|
||||
angle = getattr(row, "view_angle", None)
|
||||
key = angle.value if isinstance(angle, AssetViewAngle) else str(angle)
|
||||
if key and key not in best_by_angle:
|
||||
best_by_angle[key] = row
|
||||
|
||||
out: list[str] = []
|
||||
for angle in view_angles:
|
||||
row = best_by_angle.get(angle.value)
|
||||
if row is None:
|
||||
continue
|
||||
fid = getattr(row, "file_id", None)
|
||||
if fid:
|
||||
out.append(str(fid))
|
||||
return out
|
||||
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import async_session_maker
|
||||
from app.core.task_manager import DeliveryMode, SqlAlchemyTaskStore, TaskManager
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
from app.core.tasks import ImageGenerationInput, ImageGenerationResult, ImageGenerationTask, ProviderConfig
|
||||
from app.models.studio import (
|
||||
ActorImage,
|
||||
AssetQualityLevel,
|
||||
AssetViewAngle,
|
||||
CharacterImage,
|
||||
CostumeImage,
|
||||
PropImage,
|
||||
SceneImage,
|
||||
ShotDetail,
|
||||
ShotFrameImage,
|
||||
)
|
||||
from app.models.task_links import GenerationTaskLink
|
||||
from app.models.types import FileUsageKind
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.services.studio.file_usages import (
|
||||
first_project_id_for_actor,
|
||||
first_project_id_for_costume,
|
||||
first_project_id_for_prop,
|
||||
first_project_id_for_scene,
|
||||
sync_usage_from_character,
|
||||
sync_usage_from_shot_context,
|
||||
upsert_file_usage,
|
||||
)
|
||||
from app.services.studio.image_tasks import load_provider_config, resolve_image_model
|
||||
from app.utils.files import create_file_from_url_or_b64
|
||||
from app.api.v1.routes.film.common import TaskCreated, _CreateOnlyTask
|
||||
|
||||
|
||||
async def _persist_images_to_assets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
task_id: str,
|
||||
relation_type: str,
|
||||
relation_entity_id: str,
|
||||
result: ImageGenerationResult,
|
||||
) -> None:
|
||||
"""将图片生成结果落库到 FileItem 与业务图片表。"""
|
||||
images = result.images or []
|
||||
if not images:
|
||||
return
|
||||
|
||||
item = images[0]
|
||||
if not item.url:
|
||||
return
|
||||
|
||||
file_obj = await create_file_from_url_or_b64(
|
||||
session,
|
||||
url=item.url,
|
||||
name=f"{relation_type}-{relation_entity_id}",
|
||||
prefix=f"generated-images/{relation_type}/{relation_entity_id}",
|
||||
)
|
||||
file_id = file_obj.id
|
||||
|
||||
link_stmt = (
|
||||
select(GenerationTaskLink)
|
||||
.where(
|
||||
GenerationTaskLink.task_id == task_id,
|
||||
GenerationTaskLink.relation_type == relation_type,
|
||||
GenerationTaskLink.relation_entity_id == relation_entity_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
link_row = (await session.execute(link_stmt)).scalars().first()
|
||||
if link_row is not None:
|
||||
link_row.file_id = file_id
|
||||
|
||||
if relation_type == "actor_image":
|
||||
image_row = await session.get(ActorImage, int(relation_entity_id))
|
||||
if image_row is not None:
|
||||
image_row.file_id = file_id
|
||||
pid = await first_project_id_for_actor(session, image_row.actor_id)
|
||||
if pid:
|
||||
await upsert_file_usage(
|
||||
session,
|
||||
file_id=file_id,
|
||||
project_id=pid,
|
||||
chapter_id=None,
|
||||
shot_id=None,
|
||||
usage_kind=FileUsageKind.asset_image,
|
||||
source_ref=f"actor_image:{image_row.id}",
|
||||
)
|
||||
elif relation_type == "scene_image":
|
||||
image_row = await session.get(SceneImage, int(relation_entity_id))
|
||||
if image_row is not None:
|
||||
image_row.file_id = file_id
|
||||
pid = await first_project_id_for_scene(session, image_row.scene_id)
|
||||
if pid:
|
||||
await upsert_file_usage(
|
||||
session,
|
||||
file_id=file_id,
|
||||
project_id=pid,
|
||||
chapter_id=None,
|
||||
shot_id=None,
|
||||
usage_kind=FileUsageKind.asset_image,
|
||||
source_ref=f"scene_image:{image_row.id}",
|
||||
)
|
||||
elif relation_type == "prop_image":
|
||||
image_row = await session.get(PropImage, int(relation_entity_id))
|
||||
if image_row is not None:
|
||||
image_row.file_id = file_id
|
||||
pid = await first_project_id_for_prop(session, image_row.prop_id)
|
||||
if pid:
|
||||
await upsert_file_usage(
|
||||
session,
|
||||
file_id=file_id,
|
||||
project_id=pid,
|
||||
chapter_id=None,
|
||||
shot_id=None,
|
||||
usage_kind=FileUsageKind.asset_image,
|
||||
source_ref=f"prop_image:{image_row.id}",
|
||||
)
|
||||
elif relation_type == "costume_image":
|
||||
image_row = await session.get(CostumeImage, int(relation_entity_id))
|
||||
if image_row is not None:
|
||||
image_row.file_id = file_id
|
||||
pid = await first_project_id_for_costume(session, image_row.costume_id)
|
||||
if pid:
|
||||
await upsert_file_usage(
|
||||
session,
|
||||
file_id=file_id,
|
||||
project_id=pid,
|
||||
chapter_id=None,
|
||||
shot_id=None,
|
||||
usage_kind=FileUsageKind.asset_image,
|
||||
source_ref=f"costume_image:{image_row.id}",
|
||||
)
|
||||
elif relation_type == "character_image":
|
||||
image_row = await session.get(CharacterImage, int(relation_entity_id))
|
||||
if image_row is not None:
|
||||
image_row.file_id = file_id
|
||||
await sync_usage_from_character(
|
||||
session,
|
||||
file_id=file_id,
|
||||
character_id=image_row.character_id,
|
||||
usage_kind=FileUsageKind.character_image,
|
||||
source_ref=f"character_image:{image_row.id}",
|
||||
)
|
||||
elif relation_type == "character":
|
||||
character_id = relation_entity_id
|
||||
stmt_ci = (
|
||||
select(CharacterImage)
|
||||
.where(
|
||||
CharacterImage.character_id == character_id,
|
||||
CharacterImage.quality_level == AssetQualityLevel.low,
|
||||
CharacterImage.view_angle == AssetViewAngle.front,
|
||||
)
|
||||
.order_by(CharacterImage.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
ci = (await session.execute(stmt_ci)).scalars().first()
|
||||
if ci is not None:
|
||||
ci.file_id = file_id
|
||||
ci.format = getattr(ci, "format", "") or "png"
|
||||
else:
|
||||
ci = CharacterImage(
|
||||
character_id=character_id,
|
||||
file_id=file_id,
|
||||
quality_level=AssetQualityLevel.low,
|
||||
view_angle=AssetViewAngle.front,
|
||||
width=None,
|
||||
height=None,
|
||||
format="png",
|
||||
is_primary=True,
|
||||
)
|
||||
session.add(ci)
|
||||
|
||||
if ci is not None and getattr(ci, "is_primary", False) is True and getattr(ci, "id", None) is not None:
|
||||
stmt_clear = (
|
||||
CharacterImage.__table__.update() # type: ignore[attr-defined]
|
||||
.where(CharacterImage.character_id == character_id, CharacterImage.id != ci.id)
|
||||
.values(is_primary=False)
|
||||
)
|
||||
await session.execute(stmt_clear)
|
||||
await session.flush()
|
||||
if ci is not None:
|
||||
await sync_usage_from_character(
|
||||
session,
|
||||
file_id=file_id,
|
||||
character_id=character_id,
|
||||
usage_kind=FileUsageKind.character_image,
|
||||
source_ref=f"character_image:{ci.id}",
|
||||
)
|
||||
elif relation_type == "shot_frame_image":
|
||||
image_row = await session.get(ShotFrameImage, int(relation_entity_id))
|
||||
if image_row is not None:
|
||||
image_row.file_id = file_id
|
||||
detail = await session.get(ShotDetail, image_row.shot_detail_id)
|
||||
if detail is not None:
|
||||
await sync_usage_from_shot_context(
|
||||
session,
|
||||
file_id=file_id,
|
||||
shot_id=detail.id,
|
||||
usage_kind=FileUsageKind.shot_frame,
|
||||
source_ref=f"shot_frame_image:{image_row.id}",
|
||||
)
|
||||
|
||||
|
||||
async def create_image_task_and_link(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
model_id: str | None,
|
||||
relation_type: str,
|
||||
relation_entity_id: str,
|
||||
prompt: str,
|
||||
images: list[dict[str, str]] | None = None,
|
||||
) -> TaskCreated:
|
||||
"""创建图片生成任务,并建立任务关联。"""
|
||||
store = SqlAlchemyTaskStore(db)
|
||||
tm = TaskManager(store=store, strategies={})
|
||||
|
||||
model = await resolve_image_model(db, model_id)
|
||||
provider_cfg = await load_provider_config(db, model.provider_id)
|
||||
|
||||
run_args: dict = {
|
||||
"provider": provider_cfg.provider,
|
||||
"api_key": provider_cfg.api_key,
|
||||
"base_url": provider_cfg.base_url,
|
||||
"input": {
|
||||
"prompt": prompt,
|
||||
"model": model.name,
|
||||
},
|
||||
}
|
||||
if images:
|
||||
run_args["input"]["images"] = images
|
||||
|
||||
task_record = await tm.create(
|
||||
task=_CreateOnlyTask(),
|
||||
mode=DeliveryMode.async_polling,
|
||||
run_args=run_args,
|
||||
)
|
||||
|
||||
db.add(
|
||||
GenerationTaskLink(
|
||||
task_id=task_record.id,
|
||||
resource_type="image",
|
||||
relation_type=relation_type,
|
||||
relation_entity_id=relation_entity_id,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def _runner(task_id: str, args: dict) -> None:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
store = SqlAlchemyTaskStore(session)
|
||||
await store.set_status(task_id, TaskStatus.running)
|
||||
await store.set_progress(task_id, 10)
|
||||
|
||||
provider = str(args.get("provider") or "")
|
||||
api_key = str(args.get("api_key") or "")
|
||||
base_url = args.get("base_url")
|
||||
input_dict = dict(args.get("input") or {})
|
||||
|
||||
task = ImageGenerationTask(
|
||||
provider_config=ProviderConfig(
|
||||
provider=provider, # type: ignore[arg-type]
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
),
|
||||
input_=ImageGenerationInput.model_validate(input_dict),
|
||||
)
|
||||
await task.run()
|
||||
result = await task.get_result()
|
||||
if result is None:
|
||||
raise RuntimeError("Image generation task returned no result")
|
||||
|
||||
await store.set_result(task_id, result.model_dump())
|
||||
await _persist_images_to_assets(
|
||||
session,
|
||||
task_id=task_id,
|
||||
relation_type=relation_type,
|
||||
relation_entity_id=relation_entity_id,
|
||||
result=result,
|
||||
)
|
||||
await store.set_progress(task_id, 100)
|
||||
await store.set_status(task_id, TaskStatus.succeeded)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
async with async_session_maker() as s2:
|
||||
store = SqlAlchemyTaskStore(s2)
|
||||
await store.set_error(task_id, str(exc))
|
||||
await store.set_status(task_id, TaskStatus.failed)
|
||||
await s2.commit()
|
||||
|
||||
asyncio.create_task(_runner(task_record.id, run_args))
|
||||
return TaskCreated(task_id=task_record.id)
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import (
|
||||
Actor,
|
||||
ActorImage,
|
||||
Character,
|
||||
CharacterImage,
|
||||
Costume,
|
||||
CostumeImage,
|
||||
Prop,
|
||||
PropImage,
|
||||
Scene,
|
||||
SceneImage,
|
||||
)
|
||||
from app.services.common import entity_not_found, invalid_choice, not_belong_to, required_field
|
||||
|
||||
|
||||
async def validate_actor_image(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
actor_id: str,
|
||||
image_id: int | None,
|
||||
) -> ActorImage:
|
||||
actor = await db.get(Actor, actor_id)
|
||||
if actor is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Actor"))
|
||||
if image_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=required_field("image_id", when="actor generation"),
|
||||
)
|
||||
image_row = await db.get(ActorImage, image_id)
|
||||
if image_row is None or image_row.actor_id != actor_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "actor_id"),
|
||||
)
|
||||
return image_row
|
||||
|
||||
|
||||
async def validate_asset_image_and_relation_type(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
asset_type: str,
|
||||
asset_id: str,
|
||||
image_id: int | None,
|
||||
) -> tuple[int, str]:
|
||||
if image_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=required_field("image_id", when="asset image generation"),
|
||||
)
|
||||
asset_type_norm = asset_type.strip().lower()
|
||||
if asset_type_norm == "prop":
|
||||
asset = await db.get(Prop, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Prop"))
|
||||
image_row = await db.get(PropImage, image_id)
|
||||
if image_row is None or image_row.prop_id != asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "prop_id"),
|
||||
)
|
||||
return image_id, "prop_image"
|
||||
if asset_type_norm == "scene":
|
||||
asset = await db.get(Scene, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Scene"))
|
||||
image_row = await db.get(SceneImage, image_id)
|
||||
if image_row is None or image_row.scene_id != asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "scene_id"),
|
||||
)
|
||||
return image_id, "scene_image"
|
||||
if asset_type_norm == "costume":
|
||||
asset = await db.get(Costume, asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Costume"))
|
||||
image_row = await db.get(CostumeImage, image_id)
|
||||
if image_row is None or image_row.costume_id != asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "costume_id"),
|
||||
)
|
||||
return image_id, "costume_image"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=invalid_choice("asset_type", ["prop", "scene", "costume"]),
|
||||
)
|
||||
|
||||
|
||||
async def validate_character_image(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
character_id: str,
|
||||
image_id: int | None,
|
||||
) -> CharacterImage:
|
||||
character = await db.get(Character, character_id)
|
||||
if character is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=entity_not_found("Character"))
|
||||
if image_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=required_field("image_id", when="character image generation"),
|
||||
)
|
||||
image_row = await db.get(CharacterImage, image_id)
|
||||
if image_row is None or image_row.character_id != character_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=not_belong_to("image_id", "character_id"),
|
||||
)
|
||||
return image_row
|
||||
@@ -14,6 +14,7 @@ from app.core import storage
|
||||
from app.core.tasks import ProviderConfig
|
||||
from app.models.llm import Model, ModelCategoryKey
|
||||
from app.models.studio import AssetViewAngle, FileItem, PromptCategory, PromptTemplate, ShotFrameType
|
||||
from app.services.common import entity_not_found
|
||||
from app.services.llm import get_model_by_category, get_provider_by_id_or_obj
|
||||
|
||||
|
||||
@@ -68,7 +69,7 @@ async def load_provider_config(db: AsyncSession, provider_id: str) -> ProviderCo
|
||||
if e.status_code == status.HTTP_404_NOT_FOUND:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Provider not found for provider_id={provider_id}",
|
||||
detail=f"{entity_not_found('Provider')} for provider_id={provider_id}",
|
||||
) from e
|
||||
raise
|
||||
|
||||
@@ -313,4 +314,3 @@ def shot_frame_prompt_category(frame_type: ShotFrameType | str) -> PromptCategor
|
||||
if value == ShotFrameType.last.value:
|
||||
return PromptCategory.frame_tail_image
|
||||
return PromptCategory.frame_key_image
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""剧本分镜写库服务:将分镜结果落到 Chapter/Shot/ShotDetail。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import CameraAngle, CameraMovement, CameraShotType, Chapter, Shot, ShotDetail, VFXType
|
||||
from app.schemas.skills.script_processing import ScriptDivisionResult
|
||||
from app.services.common import entity_not_found, require_entity
|
||||
|
||||
|
||||
async def write_division_result_to_chapter(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
chapter_id: str,
|
||||
result: ScriptDivisionResult,
|
||||
) -> None:
|
||||
"""将分镜结果写入指定章节;若章节已有镜头则拒绝写入。"""
|
||||
await require_entity(
|
||||
db,
|
||||
Chapter,
|
||||
chapter_id,
|
||||
detail=entity_not_found("Chapter"),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
existing = await db.execute(select(Shot.id).where(Shot.chapter_id == chapter_id).limit(1))
|
||||
if existing.first() is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Chapter already has shots; refusing to write (write_strategy=fail)",
|
||||
)
|
||||
|
||||
for shot_division in result.shots:
|
||||
title = (shot_division.shot_name or "").strip() or f"镜头 {shot_division.index}"
|
||||
shot_id = str(uuid.uuid4())
|
||||
db.add(
|
||||
Shot(
|
||||
id=shot_id,
|
||||
chapter_id=chapter_id,
|
||||
index=shot_division.index,
|
||||
title=title,
|
||||
script_excerpt=shot_division.script_excerpt,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
ShotDetail(
|
||||
id=shot_id,
|
||||
camera_shot=CameraShotType.ms,
|
||||
angle=CameraAngle.eye_level,
|
||||
movement=CameraMovement.static,
|
||||
follow_atmosphere=True,
|
||||
vfx_type=VFXType.none,
|
||||
duration=4,
|
||||
)
|
||||
)
|
||||
|
||||
# 触发唯一约束与外键检查,确保在返回前失败。
|
||||
await db.flush()
|
||||
@@ -0,0 +1,297 @@
|
||||
"""镜头资产服务:镜头关联资产查询与项目资产 link 管理。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_order, paginate
|
||||
from app.models.studio import (
|
||||
Actor,
|
||||
Character,
|
||||
Costume,
|
||||
ProjectActorLink,
|
||||
ProjectCostumeLink,
|
||||
ProjectPropLink,
|
||||
ProjectSceneLink,
|
||||
Prop,
|
||||
Scene,
|
||||
Shot,
|
||||
ShotCharacterLink,
|
||||
)
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.studio.shots import (
|
||||
ProjectAssetLinkCreate,
|
||||
ProjectActorLinkRead,
|
||||
ProjectCostumeLinkRead,
|
||||
ProjectPropLinkRead,
|
||||
ProjectSceneLinkRead,
|
||||
ShotLinkedAssetItem,
|
||||
)
|
||||
from app.services.common import delete_if_exists, entity_not_found, invalid_choice, require_entity
|
||||
from app.services.studio.entity_specs import entity_spec, normalize_entity_type
|
||||
from app.services.studio.entity_thumbnails import resolve_thumbnail_infos, resolve_thumbnails
|
||||
from app.utils.project_links import upsert_project_link
|
||||
|
||||
|
||||
def _link_spec(entity_type: str) -> dict[str, Any]:
|
||||
t = normalize_entity_type(entity_type)
|
||||
if t == "actor":
|
||||
return {
|
||||
"model": ProjectActorLink,
|
||||
"field": "actor_id",
|
||||
"read_model": ProjectActorLinkRead,
|
||||
"asset_model": Actor,
|
||||
"not_found": entity_not_found("Actor"),
|
||||
}
|
||||
if t == "scene":
|
||||
return {
|
||||
"model": ProjectSceneLink,
|
||||
"field": "scene_id",
|
||||
"read_model": ProjectSceneLinkRead,
|
||||
"asset_model": Scene,
|
||||
"not_found": entity_not_found("Scene"),
|
||||
}
|
||||
if t == "prop":
|
||||
return {
|
||||
"model": ProjectPropLink,
|
||||
"field": "prop_id",
|
||||
"read_model": ProjectPropLinkRead,
|
||||
"asset_model": Prop,
|
||||
"not_found": entity_not_found("Prop"),
|
||||
}
|
||||
if t == "costume":
|
||||
return {
|
||||
"model": ProjectCostumeLink,
|
||||
"field": "costume_id",
|
||||
"read_model": ProjectCostumeLinkRead,
|
||||
"asset_model": Costume,
|
||||
"not_found": entity_not_found("Costume"),
|
||||
}
|
||||
raise HTTPException(status_code=400, detail=invalid_choice("entity_type", ["actor", "scene", "prop", "costume"]))
|
||||
|
||||
|
||||
async def create_project_asset_link(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
body: ProjectAssetLinkCreate,
|
||||
) -> Any:
|
||||
"""创建或补全项目-章节-镜头-资产关联。"""
|
||||
spec = _link_spec(entity_type)
|
||||
await require_entity(
|
||||
db,
|
||||
spec["asset_model"],
|
||||
body.asset_id,
|
||||
detail=spec["not_found"],
|
||||
status_code=400,
|
||||
)
|
||||
return await upsert_project_link(
|
||||
db,
|
||||
model=spec["model"],
|
||||
asset_field=spec["field"],
|
||||
asset_id=body.asset_id,
|
||||
project_id=body.project_id,
|
||||
chapter_id=body.chapter_id,
|
||||
shot_id=body.shot_id,
|
||||
)
|
||||
|
||||
|
||||
async def delete_project_asset_link(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
link_id: int,
|
||||
) -> None:
|
||||
"""删除项目-章节-镜头-资产关联。"""
|
||||
spec = _link_spec(entity_type)
|
||||
await delete_if_exists(db, spec["model"], link_id)
|
||||
|
||||
|
||||
async def list_shot_linked_assets_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> ApiResponse[PaginatedData[ShotLinkedAssetItem]]:
|
||||
"""获取镜头关联的角色/道具/场景/服装(分页)。"""
|
||||
await require_entity(db, Shot, shot_id, detail=entity_not_found("Shot"), status_code=400)
|
||||
|
||||
character_ids = (
|
||||
await db.execute(select(ShotCharacterLink.character_id).where(ShotCharacterLink.shot_id == shot_id))
|
||||
).scalars().all()
|
||||
prop_ids = (
|
||||
await db.execute(select(ProjectPropLink.prop_id).where(ProjectPropLink.shot_id == shot_id))
|
||||
).scalars().all()
|
||||
scene_ids = (
|
||||
await db.execute(select(ProjectSceneLink.scene_id).where(ProjectSceneLink.shot_id == shot_id))
|
||||
).scalars().all()
|
||||
costume_ids = (
|
||||
await db.execute(select(ProjectCostumeLink.costume_id).where(ProjectCostumeLink.shot_id == shot_id))
|
||||
).scalars().all()
|
||||
|
||||
character_ids = [x for x in dict.fromkeys(character_ids) if x]
|
||||
prop_ids = [x for x in dict.fromkeys(prop_ids) if x]
|
||||
scene_ids = [x for x in dict.fromkeys(scene_ids) if x]
|
||||
costume_ids = [x for x in dict.fromkeys(costume_ids) if x]
|
||||
|
||||
character_rows = []
|
||||
if character_ids:
|
||||
character_rows = (
|
||||
await db.execute(select(Character.id, Character.name).where(Character.id.in_(character_ids)))
|
||||
).all()
|
||||
prop_rows = []
|
||||
if prop_ids:
|
||||
prop_rows = (await db.execute(select(Prop.id, Prop.name).where(Prop.id.in_(prop_ids)))).all()
|
||||
scene_rows = []
|
||||
if scene_ids:
|
||||
scene_rows = (await db.execute(select(Scene.id, Scene.name).where(Scene.id.in_(scene_ids)))).all()
|
||||
costume_rows = []
|
||||
if costume_ids:
|
||||
costume_rows = (
|
||||
await db.execute(select(Costume.id, Costume.name).where(Costume.id.in_(costume_ids)))
|
||||
).all()
|
||||
|
||||
character_name = {str(r[0]): str(r[1]) for r in character_rows}
|
||||
prop_name = {str(r[0]): str(r[1]) for r in prop_rows}
|
||||
scene_name = {str(r[0]): str(r[1]) for r in scene_rows}
|
||||
costume_name = {str(r[0]): str(r[1]) for r in costume_rows}
|
||||
|
||||
character_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("character").image_model,
|
||||
parent_field_name="character_id",
|
||||
parent_ids=list(character_name.keys()),
|
||||
)
|
||||
prop_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("prop").image_model,
|
||||
parent_field_name="prop_id",
|
||||
parent_ids=list(prop_name.keys()),
|
||||
)
|
||||
scene_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("scene").image_model,
|
||||
parent_field_name="scene_id",
|
||||
parent_ids=list(scene_name.keys()),
|
||||
)
|
||||
costume_thumb = await resolve_thumbnail_infos(
|
||||
db,
|
||||
image_model=entity_spec("costume").image_model,
|
||||
parent_field_name="costume_id",
|
||||
parent_ids=list(costume_name.keys()),
|
||||
)
|
||||
|
||||
items: list[ShotLinkedAssetItem] = []
|
||||
for cid, name in character_name.items():
|
||||
info = character_thumb.get(cid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="character",
|
||||
id=cid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
for pid, name in prop_name.items():
|
||||
info = prop_thumb.get(pid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="prop",
|
||||
id=pid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
for sid, name in scene_name.items():
|
||||
info = scene_thumb.get(sid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="scene",
|
||||
id=sid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
for coid, name in costume_name.items():
|
||||
info = costume_thumb.get(coid) or {}
|
||||
items.append(
|
||||
ShotLinkedAssetItem(
|
||||
type="costume",
|
||||
id=coid,
|
||||
image_id=info.get("image_id"),
|
||||
file_id=info.get("file_id"),
|
||||
name=name,
|
||||
thumbnail=str(info.get("thumbnail") or ""),
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(key=lambda x: (x.type, x.name, x.id))
|
||||
total = len(items)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
return paginated_response(items[start:end], page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
async def list_project_asset_links_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
project_id: str | None,
|
||||
chapter_id: str | None,
|
||||
shot_id: str | None,
|
||||
asset_id: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[Any]]:
|
||||
"""查询项目-章节-镜头-实体关联列表(分页)。"""
|
||||
spec = _link_spec(entity_type)
|
||||
model = spec["model"]
|
||||
field_name = spec["field"]
|
||||
|
||||
stmt = select(model)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(model.project_id == project_id)
|
||||
if chapter_id is not None:
|
||||
stmt = stmt.where(model.chapter_id == chapter_id)
|
||||
if shot_id is not None:
|
||||
stmt = stmt.where(model.shot_id == shot_id)
|
||||
if asset_id is not None:
|
||||
stmt = stmt.where(getattr(model, field_name) == asset_id)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=model,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="id",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
|
||||
es = entity_spec(entity_type)
|
||||
ids = [getattr(x, field_name) for x in items if getattr(x, field_name, None)]
|
||||
thumbnails = await resolve_thumbnails(
|
||||
db,
|
||||
image_model=es.image_model,
|
||||
parent_field_name=es.id_field,
|
||||
parent_ids=ids,
|
||||
)
|
||||
read_model = spec["read_model"]
|
||||
payload = [
|
||||
read_model.model_validate(x).model_copy(update={"thumbnail": thumbnails.get(getattr(x, field_name), "")})
|
||||
for x in items
|
||||
]
|
||||
return paginated_response(payload, page=page, page_size=page_size, total=total)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""镜头角色关联服务:封装阵容查询与 upsert 规则。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import Chapter, Character, Shot, ShotCharacterLink
|
||||
from app.schemas.studio.cast import ShotCharacterLinkCreate
|
||||
from app.services.common import create_and_refresh, entity_not_found, flush_and_refresh, require_entity
|
||||
|
||||
|
||||
async def list_by_shot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
) -> list[ShotCharacterLink]:
|
||||
"""按镜头查询角色关联列表。"""
|
||||
await require_entity(db, Shot, shot_id, detail=entity_not_found("Shot"))
|
||||
|
||||
stmt = (
|
||||
select(ShotCharacterLink)
|
||||
.where(ShotCharacterLink.shot_id == shot_id)
|
||||
.order_by(ShotCharacterLink.index.asc(), ShotCharacterLink.id.asc())
|
||||
)
|
||||
return list((await db.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def upsert(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ShotCharacterLinkCreate,
|
||||
) -> ShotCharacterLink:
|
||||
"""按镜头与角色 upsert 阵容关系,并处理 index 冲突。"""
|
||||
shot = await require_entity(db, Shot, body.shot_id, detail=entity_not_found("Shot"))
|
||||
chapter = await require_entity(
|
||||
db,
|
||||
Chapter,
|
||||
shot.chapter_id,
|
||||
detail=f"{entity_not_found('Chapter')} for shot",
|
||||
status_code=400,
|
||||
)
|
||||
character = await require_entity(db, Character, body.character_id, detail=entity_not_found("Character"))
|
||||
|
||||
if character.project_id != chapter.project_id:
|
||||
raise ValueError("Character does not belong to the same project")
|
||||
|
||||
existing_same_character_stmt = select(ShotCharacterLink).where(
|
||||
ShotCharacterLink.shot_id == body.shot_id,
|
||||
ShotCharacterLink.character_id == body.character_id,
|
||||
)
|
||||
existing = (await db.execute(existing_same_character_stmt)).scalars().one_or_none()
|
||||
if existing is not None:
|
||||
existing.index = body.index
|
||||
existing.note = body.note
|
||||
return await flush_and_refresh(db, existing)
|
||||
|
||||
existing_same_index_stmt = select(ShotCharacterLink).where(
|
||||
ShotCharacterLink.shot_id == body.shot_id,
|
||||
ShotCharacterLink.index == body.index,
|
||||
)
|
||||
existing_same_index = (await db.execute(existing_same_index_stmt)).scalars().one_or_none()
|
||||
if existing_same_index is not None:
|
||||
await db.execute(delete(ShotCharacterLink).where(ShotCharacterLink.id == existing_same_index.id))
|
||||
|
||||
return await create_and_refresh(
|
||||
db,
|
||||
ShotCharacterLink(
|
||||
shot_id=body.shot_id,
|
||||
character_id=body.character_id,
|
||||
index=body.index,
|
||||
note=body.note,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""镜头细节服务:ShotDetail 的分页查询与 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_order, paginate
|
||||
from app.models.studio import Scene, Shot, ShotDetail
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.studio.shots import ShotDetailCreate, ShotDetailRead, ShotDetailUpdate
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_already_exists,
|
||||
entity_not_found,
|
||||
ensure_not_exists,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
require_entity,
|
||||
require_optional_entity,
|
||||
)
|
||||
|
||||
|
||||
async def list_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[ShotDetailRead]]:
|
||||
"""分页查询镜头细节。"""
|
||||
stmt = select(ShotDetail)
|
||||
if shot_id is not None:
|
||||
stmt = stmt.where(ShotDetail.id == shot_id)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=ShotDetail,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="id",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ShotDetailRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ShotDetailCreate,
|
||||
) -> ShotDetail:
|
||||
"""创建镜头细节。"""
|
||||
await ensure_not_exists(db, ShotDetail, body.id, detail=entity_already_exists("ShotDetail"))
|
||||
await require_entity(db, Shot, body.id, detail=entity_not_found("Shot"), status_code=400)
|
||||
await require_optional_entity(db, Scene, body.scene_id, detail=entity_not_found("Scene"), status_code=400)
|
||||
return await create_and_refresh(db, ShotDetail(**body.model_dump()))
|
||||
|
||||
|
||||
async def get(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
) -> ShotDetail:
|
||||
"""获取镜头细节。"""
|
||||
return await get_or_404(db, ShotDetail, shot_id, detail=entity_not_found("ShotDetail"))
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
body: ShotDetailUpdate,
|
||||
) -> ShotDetail:
|
||||
"""更新镜头细节。"""
|
||||
obj = await get_or_404(db, ShotDetail, shot_id, detail=entity_not_found("ShotDetail"))
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "scene_id" in update_data:
|
||||
await require_optional_entity(db, Scene, update_data["scene_id"], detail=entity_not_found("Scene"), status_code=400)
|
||||
patch_model(obj, update_data)
|
||||
return await flush_and_refresh(db, obj)
|
||||
|
||||
|
||||
async def delete(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
) -> None:
|
||||
"""删除镜头细节。"""
|
||||
await delete_if_exists(db, ShotDetail, shot_id)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""镜头对白服务:ShotDialogLine 的分页查询与 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.models.studio import Character, ShotDetail, ShotDialogLine
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.studio.shots import ShotDialogLineCreate, ShotDialogLineRead, ShotDialogLineUpdate
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_not_found,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
require_entity,
|
||||
require_optional_entity,
|
||||
)
|
||||
|
||||
|
||||
async def list_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_detail_id: str | None,
|
||||
q: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[ShotDialogLineRead]]:
|
||||
"""分页查询镜头对白。"""
|
||||
stmt = select(ShotDialogLine)
|
||||
if shot_detail_id is not None:
|
||||
stmt = stmt.where(ShotDialogLine.shot_detail_id == shot_detail_id)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[ShotDialogLine.text])
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=ShotDialogLine,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="index",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ShotDialogLineRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ShotDialogLineCreate,
|
||||
) -> ShotDialogLine:
|
||||
"""创建镜头对白。"""
|
||||
await require_entity(db, ShotDetail, body.shot_detail_id, detail=entity_not_found("ShotDetail"), status_code=400)
|
||||
await require_optional_entity(
|
||||
db, Character, body.speaker_character_id, detail=entity_not_found("Character"), status_code=400
|
||||
)
|
||||
await require_optional_entity(
|
||||
db, Character, body.target_character_id, detail=entity_not_found("Character"), status_code=400
|
||||
)
|
||||
return await create_and_refresh(db, ShotDialogLine(**body.model_dump()))
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
line_id: int,
|
||||
body: ShotDialogLineUpdate,
|
||||
) -> ShotDialogLine:
|
||||
"""更新镜头对白。"""
|
||||
obj = await get_or_404(db, ShotDialogLine, line_id, detail=entity_not_found("ShotDialogLine"))
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "speaker_character_id" in update_data:
|
||||
await require_optional_entity(
|
||||
db, Character, update_data["speaker_character_id"], detail=entity_not_found("Character"), status_code=400
|
||||
)
|
||||
if "target_character_id" in update_data:
|
||||
await require_optional_entity(
|
||||
db, Character, update_data["target_character_id"], detail=entity_not_found("Character"), status_code=400
|
||||
)
|
||||
patch_model(obj, update_data)
|
||||
return await flush_and_refresh(db, obj)
|
||||
|
||||
|
||||
async def delete(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
line_id: int,
|
||||
) -> None:
|
||||
"""删除镜头对白。"""
|
||||
await delete_if_exists(db, ShotDialogLine, line_id)
|
||||
@@ -30,7 +30,8 @@ from app.models.studio import (
|
||||
ShotDetail,
|
||||
ShotDialogLine,
|
||||
)
|
||||
from app.services.studio.entities import resolve_thumbnail_infos
|
||||
from app.services.common import entity_not_found
|
||||
from app.services.studio.entity_thumbnails import resolve_thumbnail_infos
|
||||
from app.schemas.skills.script_processing import (
|
||||
StudioAssetDraft,
|
||||
StudioCharacterDraft,
|
||||
@@ -50,10 +51,10 @@ def _dialogue_line_mode_str(mode: Any) -> str:
|
||||
async def build_script_extraction_draft_for_shot(db: AsyncSession, shot_id: str) -> StudioScriptExtractionDraft:
|
||||
shot = await db.get(Shot, shot_id)
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
raise HTTPException(status_code=404, detail=entity_not_found("Shot"))
|
||||
chapter = await db.get(Chapter, shot.chapter_id)
|
||||
if chapter is None:
|
||||
raise HTTPException(status_code=400, detail="Chapter not found for shot")
|
||||
raise HTTPException(status_code=400, detail=f"{entity_not_found('Chapter')} for shot")
|
||||
project_id = chapter.project_id
|
||||
script_text = (chapter.condensed_text or chapter.raw_text or "").strip()
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""镜头分镜帧服务:ShotFrameImage 的分页查询与 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_order, paginate
|
||||
from app.models.studio import ShotDetail, ShotFrameImage
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.studio.shots import ShotFrameImageCreate, ShotFrameImageRead, ShotFrameImageUpdate
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_not_found,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
require_entity,
|
||||
)
|
||||
|
||||
|
||||
async def list_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_detail_id: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[ShotFrameImageRead]]:
|
||||
"""分页查询镜头分镜帧图片。"""
|
||||
stmt = select(ShotFrameImage)
|
||||
if shot_detail_id is not None:
|
||||
stmt = stmt.where(ShotFrameImage.shot_detail_id == shot_detail_id)
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=ShotFrameImage,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="id",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ShotFrameImageRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ShotFrameImageCreate,
|
||||
) -> ShotFrameImage:
|
||||
"""创建镜头分镜帧图片。"""
|
||||
await require_entity(db, ShotDetail, body.shot_detail_id, detail=entity_not_found("ShotDetail"), status_code=400)
|
||||
return await create_and_refresh(db, ShotFrameImage(**body.model_dump()))
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_id: int,
|
||||
body: ShotFrameImageUpdate,
|
||||
) -> ShotFrameImage:
|
||||
"""更新镜头分镜帧图片。"""
|
||||
obj = await get_or_404(db, ShotFrameImage, image_id, detail=entity_not_found("ShotFrameImage"))
|
||||
patch_model(obj, body.model_dump(exclude_unset=True))
|
||||
return await flush_and_refresh(db, obj)
|
||||
|
||||
|
||||
async def delete(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
image_id: int,
|
||||
) -> None:
|
||||
"""删除镜头分镜帧图片。"""
|
||||
await delete_if_exists(db, ShotFrameImage, image_id)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""镜头服务:Shot 的分页查询与 CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.utils import apply_keyword_filter, apply_order, paginate
|
||||
from app.models.studio import Chapter, Shot
|
||||
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
|
||||
from app.schemas.studio.shots import ShotCreate, ShotRead, ShotUpdate
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
entity_already_exists,
|
||||
entity_not_found,
|
||||
ensure_not_exists,
|
||||
flush_and_refresh,
|
||||
get_or_404,
|
||||
patch_model,
|
||||
require_entity,
|
||||
)
|
||||
|
||||
|
||||
async def list_paginated(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
chapter_id: str | None,
|
||||
q: str | None,
|
||||
order: str | None,
|
||||
is_desc: bool,
|
||||
page: int,
|
||||
page_size: int,
|
||||
allow_fields: set[str],
|
||||
) -> ApiResponse[PaginatedData[ShotRead]]:
|
||||
"""分页查询镜头。"""
|
||||
stmt = select(Shot)
|
||||
if chapter_id is not None:
|
||||
stmt = stmt.where(Shot.chapter_id == chapter_id)
|
||||
stmt = apply_keyword_filter(stmt, q=q, fields=[Shot.title, Shot.script_excerpt])
|
||||
stmt = apply_order(
|
||||
stmt,
|
||||
model=Shot,
|
||||
order=order,
|
||||
is_desc=is_desc,
|
||||
allow_fields=allow_fields,
|
||||
default="index",
|
||||
)
|
||||
items, total = await paginate(db, stmt=stmt, page=page, page_size=page_size)
|
||||
return paginated_response(
|
||||
[ShotRead.model_validate(x) for x in items],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
body: ShotCreate,
|
||||
) -> Shot:
|
||||
"""创建镜头。"""
|
||||
await ensure_not_exists(db, Shot, body.id, detail=entity_already_exists("Shot"))
|
||||
await require_entity(db, Chapter, body.chapter_id, detail=entity_not_found("Chapter"), status_code=400)
|
||||
return await create_and_refresh(db, Shot(**body.model_dump()))
|
||||
|
||||
|
||||
async def get(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
) -> Shot:
|
||||
"""获取镜头。"""
|
||||
return await get_or_404(db, Shot, shot_id, detail=entity_not_found("Shot"))
|
||||
|
||||
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
body: ShotUpdate,
|
||||
) -> Shot:
|
||||
"""更新镜头。"""
|
||||
obj = await get_or_404(db, Shot, shot_id, detail=entity_not_found("Shot"))
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if "chapter_id" in update_data:
|
||||
await require_entity(
|
||||
db,
|
||||
Chapter,
|
||||
update_data["chapter_id"],
|
||||
detail=entity_not_found("Chapter"),
|
||||
status_code=400,
|
||||
)
|
||||
patch_model(obj, update_data)
|
||||
return await flush_and_refresh(db, obj)
|
||||
|
||||
|
||||
async def delete(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
shot_id: str,
|
||||
) -> None:
|
||||
"""删除镜头。"""
|
||||
await delete_if_exists(db, Shot, shot_id)
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import and_, case, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.studio import Chapter, Project, Shot
|
||||
from app.services.common import entity_not_found, relation_mismatch
|
||||
|
||||
|
||||
AssetField = Literal["actor_id", "scene_id", "prop_id", "costume_id"]
|
||||
@@ -14,7 +15,7 @@ AssetField = Literal["actor_id", "scene_id", "prop_id", "costume_id"]
|
||||
|
||||
async def _ensure_project_exists(db: AsyncSession, project_id: str) -> None:
|
||||
if await db.get(Project, project_id) is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project not found")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=entity_not_found("Project"))
|
||||
|
||||
|
||||
async def _ensure_chapter_optional(db: AsyncSession, *, project_id: str, chapter_id: str | None) -> None:
|
||||
@@ -22,9 +23,9 @@ async def _ensure_chapter_optional(db: AsyncSession, *, project_id: str, chapter
|
||||
return
|
||||
chapter = await db.get(Chapter, chapter_id)
|
||||
if chapter is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Chapter not found")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=entity_not_found("Chapter"))
|
||||
if chapter.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="chapter_id does not belong to project_id")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=relation_mismatch("chapter_id", "project_id"))
|
||||
|
||||
|
||||
async def _ensure_shot_optional(db: AsyncSession, *, project_id: str, chapter_id: str | None, shot_id: str | None) -> None:
|
||||
@@ -32,12 +33,12 @@ async def _ensure_shot_optional(db: AsyncSession, *, project_id: str, chapter_id
|
||||
return
|
||||
shot = await db.get(Shot, shot_id)
|
||||
if shot is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Shot not found")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=entity_not_found("Shot"))
|
||||
if chapter_id is not None and shot.chapter_id != chapter_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="shot_id does not belong to chapter_id")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=relation_mismatch("shot_id", "chapter_id"))
|
||||
chapter = await db.get(Chapter, shot.chapter_id)
|
||||
if chapter is None or chapter.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="shot_id does not belong to project_id")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=relation_mismatch("shot_id", "project_id"))
|
||||
|
||||
|
||||
async def upsert_project_link(
|
||||
@@ -116,4 +117,3 @@ async def upsert_project_link(
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
return obj
|
||||
|
||||
@@ -48,6 +48,7 @@ dev = [
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: mark test as integration (e.g. real LLM); run with -m integration when OPENAI_API_KEY is set",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -19,3 +22,24 @@ def client() -> TestClient:
|
||||
if app is None:
|
||||
pytest.skip("FastAPI app 依赖未满足(例如缺少 langgraph),跳过需要 client 的集成测试。")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""为轻量测试环境补齐 asyncio marker。"""
|
||||
config.addinivalue_line("markers", "asyncio: mark test as asyncio coroutine")
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
||||
"""在未安装 pytest-asyncio 的环境中兜底执行 async 测试。"""
|
||||
|
||||
if not inspect.iscoroutinefunction(pyfuncitem.obj):
|
||||
return None
|
||||
|
||||
funcargs = {
|
||||
arg: pyfuncitem.funcargs[arg]
|
||||
for arg in pyfuncitem._fixtureinfo.argnames
|
||||
if arg in pyfuncitem.funcargs
|
||||
}
|
||||
asyncio.run(pyfuncitem.obj(**funcargs))
|
||||
return True
|
||||
@@ -0,0 +1,151 @@
|
||||
"""接口响应壳测试:验证创建/删除/异常返回结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.studio import PromptCategory, PromptTemplate
|
||||
|
||||
|
||||
class _FakePromptDB:
|
||||
"""最小 DB 替身:仅覆盖 prompts 路由测试所需行为。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.items: dict[str, PromptTemplate] = {}
|
||||
|
||||
async def get(self, model: type, entity_id: str) -> PromptTemplate | None: # noqa: ANN401
|
||||
if model is not PromptTemplate:
|
||||
return None
|
||||
return self.items.get(entity_id)
|
||||
|
||||
async def execute(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
def add(self, obj: PromptTemplate) -> None:
|
||||
self.items[obj.id] = obj
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: PromptTemplate) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
async def delete(self, obj: PromptTemplate) -> None:
|
||||
self.items.pop(obj.id, None)
|
||||
|
||||
|
||||
def _seed_prompt(
|
||||
db: _FakePromptDB,
|
||||
*,
|
||||
prompt_id: str = "tpl-1",
|
||||
is_default: bool = False,
|
||||
is_system: bool = False,
|
||||
) -> PromptTemplate:
|
||||
now = datetime.now(UTC)
|
||||
obj = PromptTemplate(
|
||||
id=prompt_id,
|
||||
category=PromptCategory.video_prompt,
|
||||
name="视频提示词",
|
||||
preview="预览",
|
||||
content="内容",
|
||||
variables=["scene"],
|
||||
is_default=is_default,
|
||||
is_system=is_system,
|
||||
)
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
db.items[obj.id] = obj
|
||||
return obj
|
||||
|
||||
|
||||
def _override_db(db: _FakePromptDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakePromptDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_create_prompt_template_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakePromptDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/prompts",
|
||||
json={
|
||||
"category": "video_prompt",
|
||||
"name": "视频生成提示词",
|
||||
"content": "请生成视频提示词",
|
||||
"preview": "预览文案",
|
||||
"variables": ["scene", "style"],
|
||||
"is_default": True,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["category"] == "video_prompt"
|
||||
assert body["data"]["name"] == "视频生成提示词"
|
||||
assert body["data"]["is_default"] is True
|
||||
assert body["data"]["is_system"] is False
|
||||
assert body["data"]["id"]
|
||||
|
||||
|
||||
def test_delete_prompt_template_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakePromptDB()
|
||||
_seed_prompt(db, prompt_id="tpl-delete")
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/prompts/tpl-delete")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {"code": 200, "message": "success", "data": None}
|
||||
assert "tpl-delete" not in db.items
|
||||
|
||||
|
||||
def test_get_prompt_template_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakePromptDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/prompts/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body == {"code": 404, "message": "PromptTemplate not found", "data": None}
|
||||
|
||||
|
||||
def test_create_prompt_template_validation_error_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakePromptDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/prompts",
|
||||
json={
|
||||
"name": "缺字段模板",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["code"] == 422
|
||||
assert body["data"] is None
|
||||
assert "category" in body["message"]
|
||||
assert "content" in body["message"]
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.llm import Model, ModelCategoryKey, Provider
|
||||
from app.services.common import (
|
||||
create_and_refresh,
|
||||
delete_if_exists,
|
||||
ensure_not_exists,
|
||||
patch_model,
|
||||
require_entity,
|
||||
require_optional_entity,
|
||||
)
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_entity_returns_existing_object() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
await create_and_refresh(db, provider)
|
||||
|
||||
found = await require_entity(db, Provider, "p1", detail="Provider not found")
|
||||
|
||||
assert found.id == "p1"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_entity_raises_404_when_missing() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_entity(db, Provider, "missing", detail="Provider not found")
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Provider not found"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_optional_entity_returns_none_for_empty_id() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
found = await require_optional_entity(db, Provider, None, detail="Provider not found")
|
||||
assert found is None
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_not_exists_raises_on_duplicate() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
await create_and_refresh(db, provider)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await ensure_not_exists(db, Provider, "p1", detail="Provider already exists")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "Provider already exists"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_model_updates_fields_in_memory() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
await create_and_refresh(db, provider)
|
||||
|
||||
patch_model(provider, {"name": "OpenAI Updated", "description": "new desc"})
|
||||
|
||||
assert provider.name == "OpenAI Updated"
|
||||
assert provider.description == "new desc"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_if_exists_is_idempotent() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
model = Model(id="m1", name="gpt-4o-mini", category=ModelCategoryKey.text, provider_id="p1")
|
||||
await create_and_refresh(db, provider)
|
||||
await create_and_refresh(db, model)
|
||||
|
||||
await delete_if_exists(db, Model, "m1")
|
||||
await delete_if_exists(db, Model, "m1")
|
||||
|
||||
deleted = await db.get(Model, "m1")
|
||||
assert deleted is None
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""entities 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.studio import Actor, ProjectStyle, ProjectVisualStyle
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _FakeEntityDB:
|
||||
def __init__(self) -> None:
|
||||
self.actors: dict[str, Actor] = {}
|
||||
self.actor_images: list[object] = []
|
||||
|
||||
async def get(self, model: type, entity_id): # noqa: ANN401
|
||||
if model is Actor:
|
||||
return self.actors.get(entity_id)
|
||||
return None
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
if isinstance(obj, Actor):
|
||||
self.actors[obj.id] = obj
|
||||
else:
|
||||
self.actor_images.append(obj)
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: object) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
async def delete(self, obj: object) -> None:
|
||||
if isinstance(obj, Actor):
|
||||
self.actors.pop(obj.id, None)
|
||||
|
||||
async def execute(self, *_args, **_kwargs):
|
||||
return _FakeResult([])
|
||||
|
||||
|
||||
def _override_db(db: _FakeEntityDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeEntityDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_create_actor_entity_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeEntityDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/entities/actor",
|
||||
json={
|
||||
"id": "actor-1",
|
||||
"name": "演员一",
|
||||
"description": "测试演员",
|
||||
"tags": [],
|
||||
"prompt_template_id": None,
|
||||
"view_count": 2,
|
||||
"style": ProjectStyle.real_people_city.value,
|
||||
"visual_style": ProjectVisualStyle.live_action.value,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["id"] == "actor-1"
|
||||
assert body["data"]["thumbnail"] == ""
|
||||
|
||||
|
||||
def test_get_actor_entity_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeEntityDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/entities/actor/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Actor not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_actor_entity_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeEntityDB()
|
||||
actor = Actor(
|
||||
id="actor-1",
|
||||
name="演员一",
|
||||
description="",
|
||||
tags=[],
|
||||
prompt_template_id=None,
|
||||
view_count=1,
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
actor.created_at = datetime.now(UTC)
|
||||
actor.updated_at = actor.created_at
|
||||
db.actors[actor.id] = actor
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/entities/actor/actor-1")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert "actor-1" not in db.actors
|
||||
|
||||
|
||||
def test_create_entity_invalid_entity_type_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeEntityDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post("/api/v1/studio/entities/unknown", json={"id": "x"})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"code": 400,
|
||||
"message": "entity_type must be one of: actor/character/scene/prop/costume",
|
||||
"data": None,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""entities existence-check 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.services.studio.entities import StudioEntitiesService
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
pass
|
||||
|
||||
|
||||
def _override_db(db: _FakeDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_entity_existence_check_returns_success_envelope(client: TestClient, monkeypatch) -> None:
|
||||
async def _fake_check(self, **_kwargs): # noqa: ANN001
|
||||
return {
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色一",
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": "character-1",
|
||||
"link_id": None,
|
||||
}
|
||||
],
|
||||
"props": [],
|
||||
"scenes": [],
|
||||
"costumes": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(StudioEntitiesService, "check_names_existence", _fake_check)
|
||||
app.dependency_overrides[get_db] = _override_db(_FakeDB())
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/entities/existence-check",
|
||||
json={
|
||||
"project_id": "project-1",
|
||||
"shot_id": "shot-1",
|
||||
"character_names": ["角色一"],
|
||||
"prop_names": [],
|
||||
"scene_names": [],
|
||||
"costume_names": [],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色一",
|
||||
"exists": True,
|
||||
"linked_to_project": True,
|
||||
"linked_to_shot": False,
|
||||
"asset_id": "character-1",
|
||||
"link_id": None,
|
||||
}
|
||||
],
|
||||
"props": [],
|
||||
"scenes": [],
|
||||
"costumes": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_entity_existence_check_relation_error_returns_api_response(client: TestClient, monkeypatch) -> None:
|
||||
async def _fake_check(self, **_kwargs): # noqa: ANN001
|
||||
raise HTTPException(status_code=404, detail="shot_id does not belong to project_id")
|
||||
|
||||
monkeypatch.setattr(StudioEntitiesService, "check_names_existence", _fake_check)
|
||||
app.dependency_overrides[get_db] = _override_db(_FakeDB())
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/entities/existence-check",
|
||||
json={
|
||||
"project_id": "project-1",
|
||||
"shot_id": "shot-404",
|
||||
"character_names": [],
|
||||
"prop_names": [],
|
||||
"scene_names": [],
|
||||
"costume_names": [],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"code": 404,
|
||||
"message": "shot_id does not belong to project_id",
|
||||
"data": None,
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Files 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.routes.studio import files as files_route
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.studio import FileItem
|
||||
from app.models.types import FileType
|
||||
|
||||
|
||||
class _DummyDB:
|
||||
async def get(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def delete(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
def add(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _override_db(db: _DummyDB):
|
||||
async def _get_db() -> AsyncGenerator[_DummyDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_list_files_requires_project_id_when_scope_filters_set(client: TestClient) -> None:
|
||||
db = _DummyDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/files", params={"chapter_title": "第一章"})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"code": 400,
|
||||
"message": "project_id is required when chapter_title or shot_title is set",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def test_get_file_detail_not_found_returns_api_response(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_get_file_detail(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
monkeypatch.setattr(files_route, "get_file_detail_service", _fake_get_file_detail)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/files/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "File not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_file_returns_empty_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_delete_file(*_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(files_route, "delete_file", _fake_delete_file)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/files/file-1")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
|
||||
|
||||
def test_update_file_meta_returns_success_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
now = datetime.now(UTC)
|
||||
file_item = FileItem(
|
||||
id="file-1",
|
||||
type=FileType.image,
|
||||
name="封面图",
|
||||
thumbnail="https://example.com/image.png",
|
||||
tags=["cover"],
|
||||
storage_key="files/image.png",
|
||||
)
|
||||
file_item.created_at = now
|
||||
file_item.updated_at = now
|
||||
|
||||
async def _fake_update_file_meta(*_args, **_kwargs):
|
||||
return file_item
|
||||
|
||||
monkeypatch.setattr(files_route, "update_file_meta_service", _fake_update_file_meta)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.patch(
|
||||
"/api/v1/studio/files/file-1",
|
||||
json={"name": "新封面图"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["id"] == "file-1"
|
||||
assert body["data"]["name"] == "封面图"
|
||||
@@ -0,0 +1,162 @@
|
||||
"""generated_video 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.routes.film import generated_video as route
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
class _FakeTaskRecord:
|
||||
def __init__(self, task_id: str) -> None:
|
||||
self.id = task_id
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def create(self, *_args, **_kwargs) -> _FakeTaskRecord:
|
||||
return _FakeTaskRecord("video-task-1")
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
self.committed = False
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
|
||||
def _close_task_stub(coro):
|
||||
coro.close()
|
||||
return None
|
||||
|
||||
|
||||
def _override_db(db: _FakeDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_preview_video_generation_prompt_returns_success_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _FakeDB()
|
||||
|
||||
async def _fake_preview(*_args, **_kwargs):
|
||||
return "视频预览提示词", ["file-1", "file-2"], object()
|
||||
|
||||
monkeypatch.setattr(route, "preview_prompt_and_images", _fake_preview)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/video/preview-prompt",
|
||||
json={
|
||||
"shot_id": "shot-1",
|
||||
"reference_mode": "first_last",
|
||||
"prompt": "生成一个压迫感强的镜头",
|
||||
"images": [],
|
||||
"size": "720x1280",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["prompt"] == "视频预览提示词"
|
||||
assert body["data"]["images"] == ["file-1", "file-2"]
|
||||
|
||||
|
||||
def test_preview_video_generation_prompt_not_found_returns_api_response(
|
||||
client: TestClient, monkeypatch
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
|
||||
async def _fake_preview(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=404, detail="Shot not found")
|
||||
|
||||
monkeypatch.setattr(route, "preview_prompt_and_images", _fake_preview)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/video/preview-prompt",
|
||||
json={
|
||||
"shot_id": "shot-missing",
|
||||
"reference_mode": "text_only",
|
||||
"prompt": "仅文本生成",
|
||||
"images": [],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Shot not found", "data": None}
|
||||
|
||||
|
||||
def test_create_video_generation_task_returns_created_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _FakeDB()
|
||||
|
||||
async def _fake_build_run_args(*_args, **_kwargs):
|
||||
return {"prompt": "最终视频提示词", "images": ["file-1"]}
|
||||
|
||||
monkeypatch.setattr(route, "build_run_args", _fake_build_run_args)
|
||||
monkeypatch.setattr(route, "TaskManager", _FakeTaskManager)
|
||||
monkeypatch.setattr(route.asyncio, "create_task", _close_task_stub)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/video",
|
||||
json={
|
||||
"shot_id": "shot-1",
|
||||
"reference_mode": "first",
|
||||
"prompt": "生成一个节奏紧张的视频片段",
|
||||
"images": [],
|
||||
"size": "720x1280",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["task_id"] == "video-task-1"
|
||||
assert db.committed is True
|
||||
assert len(db.added) == 1
|
||||
|
||||
|
||||
def test_create_video_generation_task_validation_error_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/video",
|
||||
json={
|
||||
"shot_id": "shot-1",
|
||||
"reference_mode": "invalid-mode",
|
||||
"prompt": "bad",
|
||||
"images": [],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["code"] == 422
|
||||
assert body["data"] is None
|
||||
assert "reference_mode" in body["message"]
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.models.studio import (
|
||||
CameraAngle,
|
||||
CameraMovement,
|
||||
CameraShotType,
|
||||
Chapter,
|
||||
Project,
|
||||
ProjectStyle,
|
||||
ProjectVisualStyle,
|
||||
Shot,
|
||||
ShotDetail,
|
||||
ShotFrameImage,
|
||||
ShotFrameType,
|
||||
VFXType,
|
||||
)
|
||||
from app.services.film.generated_video import (
|
||||
build_run_args,
|
||||
preview_prompt_and_images,
|
||||
provider_key_from_db_name,
|
||||
resolve_default_video_model,
|
||||
validate_images_count,
|
||||
)
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
async def _seed_shot_graph(db: AsyncSession) -> None:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一", script_excerpt="角色推门而入。")
|
||||
detail = ShotDetail(
|
||||
id="s1",
|
||||
camera_shot=CameraShotType.ms,
|
||||
angle=CameraAngle.eye_level,
|
||||
movement=CameraMovement.static,
|
||||
duration=6,
|
||||
follow_atmosphere=True,
|
||||
vfx_type=VFXType.none,
|
||||
first_frame_prompt="首帧提示词",
|
||||
last_frame_prompt="尾帧提示词",
|
||||
key_frame_prompt="关键帧提示词",
|
||||
)
|
||||
db.add_all([project, chapter, shot, detail])
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_images_count_rejects_wrong_count() -> None:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_images_count("first_last", ["only-one"])
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "requires exactly 2 images" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_provider_key_from_db_name_supports_known_aliases() -> None:
|
||||
assert provider_key_from_db_name("OpenAI") == "openai"
|
||||
assert provider_key_from_db_name("火山引擎") == "volcengine"
|
||||
assert provider_key_from_db_name("Doubao Video") == "volcengine"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_default_video_model_requires_video_category() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
wrong_model = Model(id="m1", name="gpt-4o-mini", category=ModelCategoryKey.text, provider_id="p1")
|
||||
settings = ModelSettings(id=1, default_video_model_id="m1")
|
||||
db.add_all([provider, wrong_model, settings])
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await resolve_default_video_model(db)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "not video category" in exc_info.value.detail
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_prompt_and_images_uses_auto_frame_ids() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_shot_graph(db)
|
||||
db.add_all(
|
||||
[
|
||||
ShotFrameImage(shot_detail_id="s1", frame_type=ShotFrameType.first, file_id="f1", format="png"),
|
||||
ShotFrameImage(shot_detail_id="s1", frame_type=ShotFrameType.last, file_id="f2", format="png"),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
prompt, images, detail = await preview_prompt_and_images(
|
||||
db,
|
||||
shot_id="s1",
|
||||
reference_mode="first_last",
|
||||
prompt=None,
|
||||
)
|
||||
|
||||
assert prompt == "首帧提示词\n尾帧提示词"
|
||||
assert images == ["f1", "f2"]
|
||||
assert detail.duration == 6
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_run_args_maps_reference_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_shot_graph(db)
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
model = Model(id="m_video", name="sora-mini", category=ModelCategoryKey.video, provider_id="p1")
|
||||
settings = ModelSettings(id=1, default_video_model_id="m_video")
|
||||
db.add_all([provider, model, settings])
|
||||
await db.commit()
|
||||
|
||||
async def _fake_file_id_to_data_url(_db: AsyncSession, *, file_id: str) -> str:
|
||||
return f"data:image/png;base64,{file_id}"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.film.generated_video.file_id_to_data_url",
|
||||
_fake_file_id_to_data_url,
|
||||
)
|
||||
|
||||
run_args = await build_run_args(
|
||||
db,
|
||||
shot_id="s1",
|
||||
reference_mode="first_last",
|
||||
prompt="最终视频提示词",
|
||||
images=["img-first", "img-last"],
|
||||
size="720x1280",
|
||||
)
|
||||
|
||||
assert run_args["provider"] == "openai"
|
||||
assert run_args["api_key"] == "k"
|
||||
assert run_args["input"]["model"] == "sora-mini"
|
||||
assert run_args["input"]["first_frame_base64"] == "data:image/png;base64,img-first"
|
||||
assert run_args["input"]["last_frame_base64"] == "data:image/png;base64,img-last"
|
||||
assert run_args["input"]["key_frame_base64"] is None
|
||||
assert run_args["input"]["seconds"] == 6
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""image_task_* services 单测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models.studio import AssetViewAngle, FileItem, ShotFrameType
|
||||
from app.schemas.studio.shots import ShotLinkedAssetItem
|
||||
from app.services.studio import image_task_prompts as prompts
|
||||
from app.services.studio.image_task_references import (
|
||||
pick_ordered_ref_file_ids,
|
||||
resolve_reference_file_ids_and_names_from_linked_items,
|
||||
resolve_reference_image_refs_by_file_ids,
|
||||
)
|
||||
from app.services.studio.image_task_validation import (
|
||||
validate_actor_image,
|
||||
validate_asset_image_and_relation_type,
|
||||
validate_character_image,
|
||||
)
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeExecuteResult:
|
||||
def __init__(self, rows=None, single=None):
|
||||
self._rows = rows or []
|
||||
self._single = single
|
||||
|
||||
def scalars(self):
|
||||
return _FakeScalarResult(self._rows)
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._single
|
||||
|
||||
|
||||
class _FakeColumn:
|
||||
def is_not(self, _value):
|
||||
return self
|
||||
|
||||
def desc(self):
|
||||
return self
|
||||
|
||||
def __eq__(self, _other):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeImageModel:
|
||||
actor_id = _FakeColumn()
|
||||
file_id = _FakeColumn()
|
||||
created_at = _FakeColumn()
|
||||
id = _FakeColumn()
|
||||
|
||||
|
||||
class _FakeStmt:
|
||||
def where(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, mapping=None, execute_results=None):
|
||||
self.mapping = mapping or {}
|
||||
self.execute_results = list(execute_results or [])
|
||||
|
||||
async def get(self, model, entity_id):
|
||||
return self.mapping.get((model, entity_id))
|
||||
|
||||
async def execute(self, *_args, **_kwargs):
|
||||
if not self.execute_results:
|
||||
return _FakeExecuteResult()
|
||||
return self.execute_results.pop(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_actor_image_returns_row_when_belongs_to_actor():
|
||||
actor = SimpleNamespace(id="actor-1")
|
||||
image = SimpleNamespace(id=1, actor_id="actor-1")
|
||||
db = _FakeDB(
|
||||
mapping={
|
||||
(prompts.Actor, "actor-1"): actor,
|
||||
(prompts.ActorImage, 1): image,
|
||||
}
|
||||
)
|
||||
|
||||
row = await validate_actor_image(db, actor_id="actor-1", image_id=1)
|
||||
|
||||
assert row is image
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_asset_image_and_relation_type_rejects_invalid_asset_type():
|
||||
db = _FakeDB()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await validate_asset_image_and_relation_type(
|
||||
db,
|
||||
asset_type="invalid",
|
||||
asset_id="asset-1",
|
||||
image_id=1,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "asset_type must be one of: prop/scene/costume"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_character_image_requires_image_id():
|
||||
character = SimpleNamespace(id="char-1")
|
||||
db = _FakeDB(mapping={(prompts.Character, "char-1"): character})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await validate_character_image(db, character_id="char-1", image_id=None)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail == "image_id is required for character image generation"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_reference_file_ids_and_names_filters_empty_file_ids():
|
||||
items = [
|
||||
ShotLinkedAssetItem(id="a1", type="prop", name="道具一", file_id="file-1"),
|
||||
ShotLinkedAssetItem(id="a2", type="scene", name="", file_id="file-2"),
|
||||
ShotLinkedAssetItem(id="a3", type="costume", name="忽略项", file_id=""),
|
||||
]
|
||||
|
||||
file_ids, names = await resolve_reference_file_ids_and_names_from_linked_items(None, items=items)
|
||||
|
||||
assert file_ids == ["file-1", "file-2"]
|
||||
assert names == ["道具一", "a2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_reference_image_refs_by_file_ids_returns_data_urls(monkeypatch):
|
||||
file_obj = FileItem(id="file-1", name="sample.png", storage_key="images/sample.png")
|
||||
db = _FakeDB(mapping={(FileItem, "file-1"): file_obj})
|
||||
|
||||
async def _fake_download_file(*, key: str):
|
||||
assert key == "images/sample.png"
|
||||
return b"png-bytes"
|
||||
|
||||
async def _fake_get_file_info(*, key: str):
|
||||
assert key == "images/sample.png"
|
||||
return SimpleNamespace(content_type="image/png")
|
||||
|
||||
monkeypatch.setattr("app.services.studio.image_task_references.storage.download_file", _fake_download_file)
|
||||
monkeypatch.setattr("app.services.studio.image_task_references.storage.get_file_info", _fake_get_file_info)
|
||||
|
||||
refs = await resolve_reference_image_refs_by_file_ids(db, file_ids=["file-1"])
|
||||
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["image_url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_ordered_ref_file_ids_returns_requested_angle_order(monkeypatch):
|
||||
rows = [
|
||||
SimpleNamespace(file_id="file-back", view_angle=AssetViewAngle.back),
|
||||
SimpleNamespace(file_id="file-right", view_angle=AssetViewAngle.right),
|
||||
SimpleNamespace(file_id="file-front", view_angle=AssetViewAngle.front),
|
||||
]
|
||||
db = _FakeDB(execute_results=[_FakeExecuteResult(rows=rows)])
|
||||
monkeypatch.setattr("app.services.studio.image_task_references.select", lambda *_args, **_kwargs: _FakeStmt())
|
||||
|
||||
out = await pick_ordered_ref_file_ids(
|
||||
db,
|
||||
image_model=_FakeImageModel,
|
||||
parent_field_name="actor_id",
|
||||
parent_id="actor-1",
|
||||
view_angles=(AssetViewAngle.front, AssetViewAngle.right, AssetViewAngle.left),
|
||||
)
|
||||
|
||||
assert out == ["file-front", "file-right"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_actor_prompt_and_refs_front_view_returns_no_refs(monkeypatch):
|
||||
actor = SimpleNamespace(
|
||||
id="actor-1",
|
||||
name="演员A",
|
||||
description="沉稳男性",
|
||||
tags=["成熟", "都市"],
|
||||
visual_style="写实",
|
||||
style="影视",
|
||||
)
|
||||
image = SimpleNamespace(
|
||||
id=1,
|
||||
actor_id="actor-1",
|
||||
view_angle=AssetViewAngle.front,
|
||||
quality_level="high",
|
||||
format="png",
|
||||
)
|
||||
db = _FakeDB(
|
||||
mapping={
|
||||
(prompts.Actor, "actor-1"): actor,
|
||||
(prompts.ActorImage, 1): image,
|
||||
}
|
||||
)
|
||||
|
||||
async def _fake_build_prompt(*_args, **_kwargs):
|
||||
return "演员渲染提示词"
|
||||
|
||||
monkeypatch.setattr(prompts, "build_prompt_with_template", _fake_build_prompt)
|
||||
monkeypatch.setattr(prompts, "asset_prompt_category", lambda **_kwargs: "actor_front")
|
||||
|
||||
prompt, refs, image_row = await prompts.build_actor_prompt_and_refs(
|
||||
db,
|
||||
actor_id="actor-1",
|
||||
image_id=1,
|
||||
)
|
||||
|
||||
assert prompt == "演员渲染提示词"
|
||||
assert refs == []
|
||||
assert image_row is image
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_character_prompt_and_refs_combines_actor_and_costume_refs(monkeypatch):
|
||||
character = SimpleNamespace(
|
||||
id="char-1",
|
||||
name="角色A",
|
||||
description="主角",
|
||||
actor_id="actor-1",
|
||||
costume_id="costume-1",
|
||||
visual_style="写实",
|
||||
style="影视",
|
||||
)
|
||||
image = SimpleNamespace(
|
||||
id=1,
|
||||
character_id="char-1",
|
||||
view_angle=AssetViewAngle.front,
|
||||
quality_level="high",
|
||||
format="png",
|
||||
)
|
||||
db = _FakeDB(
|
||||
mapping={
|
||||
(prompts.Character, "char-1"): character,
|
||||
(prompts.CharacterImage, 1): image,
|
||||
(prompts.Actor, "actor-1"): SimpleNamespace(id="actor-1"),
|
||||
(prompts.Costume, "costume-1"): SimpleNamespace(id="costume-1"),
|
||||
}
|
||||
)
|
||||
|
||||
async def _fake_build_prompt(*_args, **_kwargs):
|
||||
return "角色合成提示词"
|
||||
|
||||
async def _fake_pick_ordered(*_args, parent_id: str, **_kwargs):
|
||||
if parent_id == "actor-1":
|
||||
return ["actor-front", "actor-left"]
|
||||
return ["costume-front"]
|
||||
|
||||
monkeypatch.setattr(prompts, "build_prompt_with_template", _fake_build_prompt)
|
||||
monkeypatch.setattr(prompts, "pick_ordered_ref_file_ids", _fake_pick_ordered)
|
||||
|
||||
prompt, refs, image_row = await prompts.build_character_prompt_and_refs(
|
||||
db,
|
||||
character_id="char-1",
|
||||
image_id=1,
|
||||
)
|
||||
|
||||
assert prompt == "角色合成提示词"
|
||||
assert refs == ["actor-front", "actor-left", "costume-front"]
|
||||
assert image_row is image
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_shot_frame_prompt_and_refs_replaces_character_names(monkeypatch):
|
||||
project = SimpleNamespace(visual_style="写实", style="悬疑")
|
||||
chapter = SimpleNamespace(project=project)
|
||||
shot = SimpleNamespace(chapter=chapter)
|
||||
shot_detail = SimpleNamespace(
|
||||
id="shot-1",
|
||||
shot=shot,
|
||||
description="雨夜对峙",
|
||||
atmosphere="紧张",
|
||||
mood_tags=["压迫", "危险"],
|
||||
camera_shot="近景",
|
||||
angle="平视",
|
||||
movement="推镜",
|
||||
first_frame_prompt="张三在雨夜中回头",
|
||||
last_frame_prompt="张三和李四同时看向门口",
|
||||
key_frame_prompt="张三逼近李四",
|
||||
)
|
||||
role_links = [
|
||||
SimpleNamespace(character=SimpleNamespace(id="char-1", name="张三")),
|
||||
SimpleNamespace(character=SimpleNamespace(id="char-2", name="李四")),
|
||||
]
|
||||
db = _FakeDB(
|
||||
execute_results=[
|
||||
_FakeExecuteResult(single=shot_detail),
|
||||
_FakeExecuteResult(rows=role_links),
|
||||
]
|
||||
)
|
||||
|
||||
async def _fake_pick_front(*_args, parent_id: str, **_kwargs):
|
||||
return f"{parent_id}-front"
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def _fake_build_prompt(*_args, **kwargs):
|
||||
captured.update(kwargs["variables"])
|
||||
return "镜头帧提示词"
|
||||
|
||||
monkeypatch.setattr(prompts, "pick_front_ref_file_id", _fake_pick_front)
|
||||
monkeypatch.setattr(prompts, "build_prompt_with_template", _fake_build_prompt)
|
||||
monkeypatch.setattr(prompts, "shot_frame_prompt_category", lambda _ft: "shot_frame")
|
||||
|
||||
prompt, refs, detail = await prompts.build_shot_frame_prompt_and_refs(
|
||||
db,
|
||||
shot_id="shot-1",
|
||||
frame_type=ShotFrameType.first,
|
||||
)
|
||||
|
||||
assert prompt == "镜头帧提示词"
|
||||
assert refs == ["char-1-front", "char-2-front"]
|
||||
assert detail is shot_detail
|
||||
assert captured["base_prompt"] == "图一在雨夜中回头"
|
||||
assert captured["key_frame_prompt"] == "图一逼近图二"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""image_tasks 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.routes.studio import image_tasks as route
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
class _DummyDB:
|
||||
async def get(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _override_db(db: _DummyDB):
|
||||
async def _get_db() -> AsyncGenerator[_DummyDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_create_actor_image_task_requires_prompt(client: TestClient) -> None:
|
||||
db = _DummyDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/image-tasks/actors/actor-1/image-tasks",
|
||||
json={"image_id": 1, "prompt": " ", "images": []},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"code": 400,
|
||||
"message": "prompt is required for actor generation",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def test_render_actor_image_prompt_returns_success_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_build(*_args, **_kwargs):
|
||||
return "渲染后的演员提示词", ["file-1", "file-2"], object()
|
||||
|
||||
monkeypatch.setattr(route, "_build_actor_prompt_and_refs_service", _fake_build)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/image-tasks/actors/actor-1/render-prompt",
|
||||
json={"image_id": 1, "images": []},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["prompt"] == "渲染后的演员提示词"
|
||||
assert body["data"]["images"] == ["file-1", "file-2"]
|
||||
|
||||
|
||||
def test_create_shot_frame_image_task_requires_prompt(client: TestClient) -> None:
|
||||
db = _DummyDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/image-tasks/shot/shot-1/frame-image-tasks",
|
||||
json={"frame_type": "first", "prompt": " ", "images": []},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"code": 400,
|
||||
"message": "prompt is required for shot frame generation",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def test_render_shot_frame_prompt_returns_success_envelope_when_prompt_given(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_resolve(*_args, **_kwargs):
|
||||
return ["file-1", "file-2"], ["角色正面", "场景远景"]
|
||||
|
||||
monkeypatch.setattr(route, "_resolve_reference_file_ids_and_names_from_linked_items_service", _fake_resolve)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/image-tasks/shot/shot-1/frame-render-prompt",
|
||||
json={
|
||||
"frame_type": "first",
|
||||
"prompt": "生成一个紧张的首帧画面",
|
||||
"images": [],
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["images"] == ["file-1", "file-2"]
|
||||
assert "图1: 角色正面" in body["data"]["prompt"]
|
||||
assert "生成一个紧张的首帧画面" in body["data"]["prompt"]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""LLM 接口响应壳测试:聚焦 Provider CRUD。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.llm import Provider, ProviderStatus
|
||||
|
||||
|
||||
class _FakeLlmDB:
|
||||
"""最小 DB 替身:仅覆盖 Provider 路由测试所需行为。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.providers: dict[str, Provider] = {}
|
||||
|
||||
async def get(self, model: type, entity_id: str) -> Provider | None: # noqa: ANN401
|
||||
if model is not Provider:
|
||||
return None
|
||||
return self.providers.get(entity_id)
|
||||
|
||||
def add(self, obj: Provider) -> None:
|
||||
self.providers[obj.id] = obj
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: Provider) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
async def delete(self, obj: Provider) -> None:
|
||||
self.providers.pop(obj.id, None)
|
||||
|
||||
|
||||
def _seed_provider(db: _FakeLlmDB, provider_id: str = "p-1") -> Provider:
|
||||
now = datetime.now(UTC)
|
||||
obj = Provider(
|
||||
id=provider_id,
|
||||
name="OpenAI",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="secret",
|
||||
api_secret="",
|
||||
description="说明",
|
||||
status=ProviderStatus.testing,
|
||||
created_by="tester",
|
||||
)
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
db.providers[obj.id] = obj
|
||||
return obj
|
||||
|
||||
|
||||
def _override_db(db: _FakeLlmDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeLlmDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_create_provider_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeLlmDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/llm/providers",
|
||||
json={
|
||||
"id": "p-create",
|
||||
"name": "OpenAI",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "secret",
|
||||
"api_secret": "",
|
||||
"description": "说明",
|
||||
"status": "testing",
|
||||
"created_by": "tester",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["id"] == "p-create"
|
||||
assert body["data"]["name"] == "OpenAI"
|
||||
assert body["data"]["base_url"] == "https://api.openai.com/v1"
|
||||
assert "api_key" not in body["data"]
|
||||
|
||||
|
||||
def test_get_provider_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeLlmDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/llm/providers/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Provider not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_provider_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeLlmDB()
|
||||
_seed_provider(db, "p-delete")
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/llm/providers/p-delete")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert "p-delete" not in db.providers
|
||||
|
||||
|
||||
def test_create_provider_validation_error_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeLlmDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/llm/providers",
|
||||
json={
|
||||
"id": "p-invalid",
|
||||
"name": "OpenAI",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["code"] == 422
|
||||
assert body["data"] is None
|
||||
assert "base_url" in body["message"]
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.llm import LogLevel, Model, ModelCategoryKey, ModelSettings, Provider
|
||||
from app.schemas.llm import ModelCreate, ModelSettingsUpdate, ModelUpdate, ProviderCreate
|
||||
from app.services.llm.manage import (
|
||||
create_model,
|
||||
create_provider,
|
||||
get_or_create_settings,
|
||||
list_models_paginated,
|
||||
update_model,
|
||||
update_model_settings,
|
||||
)
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_model_clears_previous_default_in_same_category() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await create_provider(
|
||||
db,
|
||||
body=ProviderCreate(
|
||||
id="p1",
|
||||
name="OpenAI",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="k",
|
||||
),
|
||||
)
|
||||
await create_model(
|
||||
db,
|
||||
body=ModelCreate(
|
||||
id="m1",
|
||||
name="gpt-4o-mini",
|
||||
category=ModelCategoryKey.text,
|
||||
provider_id="p1",
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
await create_model(
|
||||
db,
|
||||
body=ModelCreate(
|
||||
id="m2",
|
||||
name="gpt-4.1-mini",
|
||||
category=ModelCategoryKey.text,
|
||||
provider_id="p1",
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
|
||||
first_model = await db.get(Model, "m1")
|
||||
second_model = await db.get(Model, "m2")
|
||||
assert first_model is not None and first_model.is_default is False
|
||||
assert second_model is not None and second_model.is_default is True
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_model_uses_target_category_when_setting_default() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
db.add(provider)
|
||||
db.add_all(
|
||||
[
|
||||
Model(
|
||||
id="m_text",
|
||||
name="gpt-4o-mini",
|
||||
category=ModelCategoryKey.text,
|
||||
provider_id="p1",
|
||||
is_default=False,
|
||||
),
|
||||
Model(
|
||||
id="m_image_old_default",
|
||||
name="gpt-image-1",
|
||||
category=ModelCategoryKey.image,
|
||||
provider_id="p1",
|
||||
is_default=True,
|
||||
),
|
||||
Model(
|
||||
id="m_image_candidate",
|
||||
name="seedream",
|
||||
category=ModelCategoryKey.text,
|
||||
provider_id="p1",
|
||||
is_default=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
updated = await update_model(
|
||||
db,
|
||||
model_id="m_image_candidate",
|
||||
body=ModelUpdate(category=ModelCategoryKey.image, is_default=True),
|
||||
)
|
||||
|
||||
old_default = await db.get(Model, "m_image_old_default")
|
||||
candidate = await db.get(Model, "m_image_candidate")
|
||||
text_model = await db.get(Model, "m_text")
|
||||
|
||||
assert updated.category == ModelCategoryKey.image
|
||||
assert old_default is not None and old_default.is_default is False
|
||||
assert candidate is not None and candidate.is_default is True
|
||||
assert text_model is not None and text_model.is_default is False
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_settings_behaves_like_singleton() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
first = await get_or_create_settings(db)
|
||||
second = await get_or_create_settings(db)
|
||||
|
||||
rows = (await db.execute(select(ModelSettings))).scalars().all()
|
||||
|
||||
assert first.id == 1
|
||||
assert second.id == 1
|
||||
assert len(rows) == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_model_settings_persists_latest_values() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
updated = await update_model_settings(
|
||||
db,
|
||||
body=ModelSettingsUpdate(api_timeout=45, log_level=LogLevel.debug),
|
||||
)
|
||||
|
||||
stored = await db.get(ModelSettings, 1)
|
||||
assert updated.id == 1
|
||||
assert updated.api_timeout == 45
|
||||
assert updated.log_level == LogLevel.debug
|
||||
assert stored is not None and stored.api_timeout == 45
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_models_paginated_returns_filtered_items() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
|
||||
db.add(provider)
|
||||
db.add_all(
|
||||
[
|
||||
Model(id="m1", name="gpt-4o-mini", category=ModelCategoryKey.text, provider_id="p1"),
|
||||
Model(id="m2", name="seedream", category=ModelCategoryKey.image, provider_id="p1"),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
resp = await list_models_paginated(
|
||||
db,
|
||||
provider_id="p1",
|
||||
category=ModelCategoryKey.image,
|
||||
q="seed",
|
||||
order="created_at",
|
||||
is_desc=False,
|
||||
page=1,
|
||||
page_size=10,
|
||||
allow_fields={"created_at", "name"},
|
||||
)
|
||||
|
||||
assert resp.data is not None
|
||||
assert resp.data.pagination.total == 1
|
||||
assert [item.id for item in resp.data.items] == ["m2"]
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.studio import (
|
||||
CameraAngle,
|
||||
CameraMovement,
|
||||
CameraShotType,
|
||||
Chapter,
|
||||
Project,
|
||||
ProjectStyle,
|
||||
ProjectVisualStyle,
|
||||
Shot,
|
||||
ShotDetail,
|
||||
VFXType,
|
||||
)
|
||||
from app.schemas.skills.script_processing import ScriptDivisionResult, ShotDivision
|
||||
from app.services.studio.script_division import write_division_result_to_chapter
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
def _division_result() -> ScriptDivisionResult:
|
||||
return ScriptDivisionResult(
|
||||
shots=[
|
||||
ShotDivision(
|
||||
index=1,
|
||||
start_line=1,
|
||||
end_line=3,
|
||||
script_excerpt="主角走进房间。",
|
||||
shot_name="进入房间",
|
||||
),
|
||||
ShotDivision(
|
||||
index=2,
|
||||
start_line=4,
|
||||
end_line=6,
|
||||
script_excerpt="镜头切到窗边。",
|
||||
shot_name="窗边对话",
|
||||
),
|
||||
],
|
||||
total_shots=2,
|
||||
notes="test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_division_result_to_empty_chapter_creates_shots_and_details() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
db.add_all([project, chapter])
|
||||
await db.commit()
|
||||
|
||||
await write_division_result_to_chapter(db, chapter_id="c1", result=_division_result())
|
||||
|
||||
shots = (await db.execute(select(Shot).order_by(Shot.index.asc()))).scalars().all()
|
||||
details = (await db.execute(select(ShotDetail).order_by(ShotDetail.id.asc()))).scalars().all()
|
||||
|
||||
assert [shot.title for shot in shots] == ["进入房间", "窗边对话"]
|
||||
assert [shot.index for shot in shots] == [1, 2]
|
||||
assert len(details) == 2
|
||||
assert all(detail.camera_shot == CameraShotType.ms for detail in details)
|
||||
assert all(detail.angle == CameraAngle.eye_level for detail in details)
|
||||
assert all(detail.movement == CameraMovement.static for detail in details)
|
||||
assert all(detail.duration == 4 for detail in details)
|
||||
assert all(detail.vfx_type == VFXType.none for detail in details)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_division_result_refuses_when_chapter_already_has_shots() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
existing_shot = Shot(id="s1", chapter_id="c1", index=1, title="已有镜头")
|
||||
db.add_all([project, chapter, existing_shot])
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await write_division_result_to_chapter(db, chapter_id="c1", result=_division_result())
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "already has shots" in exc_info.value.detail
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.studio import Chapter, Character, Project, ProjectStyle, ProjectVisualStyle, Shot, ShotCharacterLink
|
||||
from app.schemas.studio.cast import ShotCharacterLinkCreate
|
||||
from app.services.studio.shot_character_links import list_by_shot, upsert
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
async def _seed_base_graph(db: AsyncSession) -> None:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
other_project = Project(
|
||||
id="p2",
|
||||
name="项目二",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一")
|
||||
character_1 = Character(
|
||||
id="char1",
|
||||
project_id="p1",
|
||||
name="角色一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
character_2 = Character(
|
||||
id="char2",
|
||||
project_id="p1",
|
||||
name="角色二",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
foreign_character = Character(
|
||||
id="char3",
|
||||
project_id="p2",
|
||||
name="跨项目角色",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
db.add_all([project, other_project, chapter, shot, character_1, character_2, foreign_character])
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_rejects_character_from_other_project() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_base_graph(db)
|
||||
|
||||
with pytest.raises(ValueError, match="same project"):
|
||||
await upsert(
|
||||
db,
|
||||
body=ShotCharacterLinkCreate(shot_id="s1", character_id="char3", index=0, note="bad"),
|
||||
)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_updates_existing_same_character_link() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_base_graph(db)
|
||||
|
||||
created = await upsert(
|
||||
db,
|
||||
body=ShotCharacterLinkCreate(shot_id="s1", character_id="char1", index=0, note="first"),
|
||||
)
|
||||
updated = await upsert(
|
||||
db,
|
||||
body=ShotCharacterLinkCreate(shot_id="s1", character_id="char1", index=2, note="updated"),
|
||||
)
|
||||
|
||||
rows = (await db.execute(select(ShotCharacterLink))).scalars().all()
|
||||
|
||||
assert created.id == updated.id
|
||||
assert updated.index == 2
|
||||
assert updated.note == "updated"
|
||||
assert len(rows) == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_replaces_existing_same_index_link() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_base_graph(db)
|
||||
|
||||
first = await upsert(
|
||||
db,
|
||||
body=ShotCharacterLinkCreate(shot_id="s1", character_id="char1", index=1, note="first"),
|
||||
)
|
||||
second = await upsert(
|
||||
db,
|
||||
body=ShotCharacterLinkCreate(shot_id="s1", character_id="char2", index=1, note="second"),
|
||||
)
|
||||
|
||||
listed = await list_by_shot(db, shot_id="s1")
|
||||
|
||||
assert first.character_id == "char1"
|
||||
assert second.character_id == "char2"
|
||||
assert len(listed) == 1
|
||||
assert listed[0].character_id == "char2"
|
||||
assert listed[0].note == "second"
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""shot_character_links 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.routes.studio import shot_character_links as route
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.studio import ShotCharacterLink
|
||||
|
||||
|
||||
class _DummyDB:
|
||||
pass
|
||||
|
||||
|
||||
def _override_db(db: _DummyDB):
|
||||
async def _get_db() -> AsyncGenerator[_DummyDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def _make_link() -> ShotCharacterLink:
|
||||
obj = ShotCharacterLink(
|
||||
shot_id="shot-1",
|
||||
character_id="char-1",
|
||||
index=1,
|
||||
note="主角",
|
||||
)
|
||||
obj.id = 1
|
||||
obj.created_at = datetime.now(UTC)
|
||||
obj.updated_at = obj.created_at
|
||||
return obj
|
||||
|
||||
|
||||
def test_list_shot_character_links_returns_success_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_list_by_shot(*_args, **_kwargs):
|
||||
return [_make_link()]
|
||||
|
||||
monkeypatch.setattr(route, "list_by_shot", _fake_list_by_shot)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/shot-character-links", params={"shot_id": "shot-1"})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"][0]["shot_id"] == "shot-1"
|
||||
assert body["data"][0]["character_id"] == "char-1"
|
||||
|
||||
|
||||
def test_upsert_shot_character_link_returns_success_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_upsert(*_args, **_kwargs):
|
||||
return _make_link()
|
||||
|
||||
monkeypatch.setattr(route, "upsert", _fake_upsert)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shot-character-links",
|
||||
json={
|
||||
"shot_id": "shot-1",
|
||||
"character_id": "char-1",
|
||||
"index": 1,
|
||||
"note": "主角",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["id"] == 1
|
||||
assert body["data"]["note"] == "主角"
|
||||
|
||||
|
||||
def test_upsert_shot_character_link_value_error_returns_api_response(client: TestClient, monkeypatch) -> None:
|
||||
db = _DummyDB()
|
||||
|
||||
async def _fake_upsert(*_args, **_kwargs):
|
||||
raise ValueError("Character does not belong to the same project")
|
||||
|
||||
monkeypatch.setattr(route, "upsert", _fake_upsert)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shot-character-links",
|
||||
json={
|
||||
"shot_id": "shot-1",
|
||||
"character_id": "char-2",
|
||||
"index": 2,
|
||||
"note": "",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"code": 400,
|
||||
"message": "Character does not belong to the same project",
|
||||
"data": None,
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.studio import (
|
||||
CameraAngle,
|
||||
CameraMovement,
|
||||
CameraShotType,
|
||||
Chapter,
|
||||
DialogueLineMode,
|
||||
Project,
|
||||
ProjectStyle,
|
||||
ProjectVisualStyle,
|
||||
Shot,
|
||||
ShotDetail,
|
||||
ShotDialogLine,
|
||||
VFXType,
|
||||
)
|
||||
from app.services.film.shot_frame_prompt_tasks import (
|
||||
build_run_args,
|
||||
normalize_frame_type,
|
||||
relation_type_for_frame,
|
||||
)
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
async def _seed_shot_graph(db: AsyncSession) -> None:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一", script_excerpt="角色推门而入。")
|
||||
detail = ShotDetail(
|
||||
id="s1",
|
||||
camera_shot=CameraShotType.ms,
|
||||
angle=CameraAngle.eye_level,
|
||||
movement=CameraMovement.static,
|
||||
duration=5,
|
||||
atmosphere="压抑",
|
||||
mood_tags=["紧张", "克制"],
|
||||
follow_atmosphere=True,
|
||||
vfx_type=VFXType.none,
|
||||
vfx_note="无",
|
||||
)
|
||||
line = ShotDialogLine(
|
||||
shot_detail_id="s1",
|
||||
index=0,
|
||||
text="我们到了。",
|
||||
line_mode=DialogueLineMode.dialogue,
|
||||
speaker_name="主角",
|
||||
)
|
||||
db.add_all([project, chapter, shot, detail, line])
|
||||
await db.commit()
|
||||
|
||||
|
||||
def test_normalize_frame_type_and_relation_type() -> None:
|
||||
assert normalize_frame_type(" First ") == "first"
|
||||
assert relation_type_for_frame("first") == "shot_first_frame_prompt"
|
||||
assert relation_type_for_frame("last") == "shot_last_frame_prompt"
|
||||
assert relation_type_for_frame("key") == "shot_key_frame_prompt"
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
normalize_frame_type("middle")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_run_args_aggregates_dialog_and_project_style() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_shot_graph(db)
|
||||
|
||||
run_args = await build_run_args(db, shot_id="s1", frame_type="first")
|
||||
|
||||
assert run_args["frame_type"] == "first"
|
||||
assert run_args["shot_id"] == "s1"
|
||||
assert run_args["input"]["title"] == "镜头一"
|
||||
assert run_args["input"]["script_excerpt"] == "角色推门而入。"
|
||||
assert run_args["input"]["camera_shot"] == "MS"
|
||||
assert run_args["input"]["angle"] == "EYE_LEVEL"
|
||||
assert run_args["input"]["movement"] == "STATIC"
|
||||
assert run_args["input"]["dialog_summary"] == "我们到了。"
|
||||
assert run_args["input"]["visual_style"] == ProjectVisualStyle.live_action.value
|
||||
assert run_args["input"]["style"] == ProjectStyle.real_people_city.value
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_run_args_requires_shot_detail() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一")
|
||||
db.add_all([project, chapter, shot])
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await build_run_args(db, shot_id="s1", frame_type="key")
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "ShotDetail not found"
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,412 @@
|
||||
"""镜头子资源接口响应壳测试:细节 / 对白 / 分镜帧。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.studio import (
|
||||
CameraAngle,
|
||||
CameraMovement,
|
||||
CameraShotType,
|
||||
Chapter,
|
||||
ChapterStatus,
|
||||
DialogueLineMode,
|
||||
Project,
|
||||
ProjectStyle,
|
||||
ProjectVisualStyle,
|
||||
Shot,
|
||||
ShotDetail,
|
||||
ShotDialogLine,
|
||||
ShotFrameImage,
|
||||
ShotFrameType,
|
||||
ShotStatus,
|
||||
VFXType,
|
||||
)
|
||||
|
||||
|
||||
class _FakeShotSubresourceDB:
|
||||
"""最小 DB 替身:仅覆盖镜头子资源接口测试所需行为。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.projects: dict[str, Project] = {}
|
||||
self.chapters: dict[str, Chapter] = {}
|
||||
self.shots: dict[str, Shot] = {}
|
||||
self.shot_details: dict[str, ShotDetail] = {}
|
||||
self.dialog_lines: dict[int, ShotDialogLine] = {}
|
||||
self.frame_images: dict[int, ShotFrameImage] = {}
|
||||
self._line_id = 1
|
||||
self._frame_id = 1
|
||||
|
||||
async def get(self, model: type, entity_id): # noqa: ANN001
|
||||
if model is Project:
|
||||
return self.projects.get(entity_id)
|
||||
if model is Chapter:
|
||||
return self.chapters.get(entity_id)
|
||||
if model is Shot:
|
||||
return self.shots.get(entity_id)
|
||||
if model is ShotDetail:
|
||||
return self.shot_details.get(entity_id)
|
||||
if model is ShotDialogLine:
|
||||
return self.dialog_lines.get(entity_id)
|
||||
if model is ShotFrameImage:
|
||||
return self.frame_images.get(entity_id)
|
||||
return None
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
if isinstance(obj, Project):
|
||||
self.projects[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, Chapter):
|
||||
self.chapters[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, Shot):
|
||||
self.shots[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, ShotDetail):
|
||||
self.shot_details[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, ShotDialogLine):
|
||||
if getattr(obj, "id", None) is None:
|
||||
obj.id = self._line_id
|
||||
self._line_id += 1
|
||||
self.dialog_lines[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, ShotFrameImage):
|
||||
if getattr(obj, "id", None) is None:
|
||||
obj.id = self._frame_id
|
||||
self._frame_id += 1
|
||||
self.frame_images[obj.id] = obj
|
||||
return
|
||||
raise TypeError(f"Unsupported object type: {type(obj)!r}")
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: object) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
async def delete(self, obj: object) -> None:
|
||||
if isinstance(obj, ShotDetail):
|
||||
self.shot_details.pop(obj.id, None)
|
||||
return
|
||||
if isinstance(obj, ShotDialogLine):
|
||||
self.dialog_lines.pop(obj.id, None)
|
||||
return
|
||||
if isinstance(obj, ShotFrameImage):
|
||||
self.frame_images.pop(obj.id, None)
|
||||
return
|
||||
raise TypeError(f"Unsupported object type: {type(obj)!r}")
|
||||
|
||||
|
||||
def _override_db(db: _FakeShotSubresourceDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeShotSubresourceDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def _seed_project_and_shot(db: _FakeShotSubresourceDB) -> None:
|
||||
now = datetime.now(UTC)
|
||||
project = Project(
|
||||
id="proj-1",
|
||||
name="测试项目",
|
||||
description="项目说明",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
seed=1,
|
||||
unify_style=True,
|
||||
progress=0,
|
||||
stats={},
|
||||
)
|
||||
project.created_at = now
|
||||
project.updated_at = now
|
||||
db.projects[project.id] = project
|
||||
|
||||
chapter = Chapter(
|
||||
id="ch-1",
|
||||
project_id=project.id,
|
||||
index=1,
|
||||
title="第一章",
|
||||
summary="章节摘要",
|
||||
raw_text="原文",
|
||||
condensed_text="精简原文",
|
||||
storyboard_count=0,
|
||||
status=ChapterStatus.draft,
|
||||
)
|
||||
chapter.created_at = now
|
||||
chapter.updated_at = now
|
||||
db.chapters[chapter.id] = chapter
|
||||
|
||||
shot = Shot(
|
||||
id="shot-1",
|
||||
chapter_id=chapter.id,
|
||||
index=1,
|
||||
title="镜头一",
|
||||
thumbnail="",
|
||||
status=ShotStatus.pending,
|
||||
script_excerpt="剧本摘录",
|
||||
generated_video_file_id=None,
|
||||
)
|
||||
shot.created_at = now
|
||||
shot.updated_at = now
|
||||
db.shots[shot.id] = shot
|
||||
|
||||
|
||||
def _seed_shot_detail(db: _FakeShotSubresourceDB, shot_id: str = "shot-1") -> ShotDetail:
|
||||
now = datetime.now(UTC)
|
||||
detail = ShotDetail(
|
||||
id=shot_id,
|
||||
camera_shot=CameraShotType.ms,
|
||||
angle=CameraAngle.eye_level,
|
||||
movement=CameraMovement.static,
|
||||
scene_id=None,
|
||||
duration=4,
|
||||
mood_tags=[],
|
||||
atmosphere="",
|
||||
follow_atmosphere=True,
|
||||
has_bgm=False,
|
||||
vfx_type=VFXType.none,
|
||||
vfx_note="",
|
||||
description="",
|
||||
prompt_template_id=None,
|
||||
first_frame_prompt="",
|
||||
last_frame_prompt="",
|
||||
key_frame_prompt="",
|
||||
)
|
||||
detail.created_at = now
|
||||
detail.updated_at = now
|
||||
db.shot_details[detail.id] = detail
|
||||
return detail
|
||||
|
||||
|
||||
def test_create_shot_detail_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
_seed_project_and_shot(db)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shot-details",
|
||||
json={
|
||||
"id": "shot-1",
|
||||
"camera_shot": "MS",
|
||||
"angle": "EYE_LEVEL",
|
||||
"movement": "STATIC",
|
||||
"scene_id": None,
|
||||
"duration": 4,
|
||||
"mood_tags": [],
|
||||
"atmosphere": "",
|
||||
"follow_atmosphere": True,
|
||||
"has_bgm": False,
|
||||
"vfx_type": "NONE",
|
||||
"vfx_note": "",
|
||||
"first_frame_prompt": "",
|
||||
"last_frame_prompt": "",
|
||||
"key_frame_prompt": "",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["data"]["id"] == "shot-1"
|
||||
|
||||
|
||||
def test_get_shot_detail_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/shot-details/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "ShotDetail not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_shot_detail_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
_seed_project_and_shot(db)
|
||||
_seed_shot_detail(db)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/shot-details/shot-1")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert "shot-1" not in db.shot_details
|
||||
|
||||
|
||||
def test_create_shot_dialog_line_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
_seed_project_and_shot(db)
|
||||
_seed_shot_detail(db)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shot-dialog-lines",
|
||||
json={
|
||||
"shot_detail_id": "shot-1",
|
||||
"index": 1,
|
||||
"text": "你好",
|
||||
"line_mode": "DIALOGUE",
|
||||
"speaker_character_id": None,
|
||||
"target_character_id": None,
|
||||
"speaker_name": None,
|
||||
"target_name": None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["data"]["text"] == "你好"
|
||||
assert body["data"]["id"] == 1
|
||||
|
||||
|
||||
def test_update_shot_dialog_line_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.patch(
|
||||
"/api/v1/studio/shot-dialog-lines/999",
|
||||
json={"text": "更新后的台词"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "ShotDialogLine not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_shot_dialog_line_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
_seed_project_and_shot(db)
|
||||
_seed_shot_detail(db)
|
||||
line = ShotDialogLine(
|
||||
shot_detail_id="shot-1",
|
||||
index=1,
|
||||
text="你好",
|
||||
line_mode=DialogueLineMode.dialogue,
|
||||
speaker_character_id=None,
|
||||
target_character_id=None,
|
||||
speaker_name=None,
|
||||
target_name=None,
|
||||
)
|
||||
line.id = 1
|
||||
line.created_at = datetime.now(UTC)
|
||||
line.updated_at = line.created_at
|
||||
db.dialog_lines[line.id] = line
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/shot-dialog-lines/1")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert 1 not in db.dialog_lines
|
||||
|
||||
|
||||
def test_create_shot_frame_image_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
_seed_project_and_shot(db)
|
||||
_seed_shot_detail(db)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shot-frame-images",
|
||||
json={
|
||||
"shot_detail_id": "shot-1",
|
||||
"frame_type": "first",
|
||||
"file_id": None,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"format": "png",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["data"]["id"] == 1
|
||||
assert body["data"]["frame_type"] == "first"
|
||||
|
||||
|
||||
def test_update_shot_frame_image_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.patch(
|
||||
"/api/v1/studio/shot-frame-images/999",
|
||||
json={"format": "jpg"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "ShotFrameImage not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_shot_frame_image_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
_seed_project_and_shot(db)
|
||||
_seed_shot_detail(db)
|
||||
frame = ShotFrameImage(
|
||||
shot_detail_id="shot-1",
|
||||
frame_type=ShotFrameType.first,
|
||||
file_id=None,
|
||||
width=1280,
|
||||
height=720,
|
||||
format="png",
|
||||
)
|
||||
frame.id = 1
|
||||
frame.created_at = datetime.now(UTC)
|
||||
frame.updated_at = frame.created_at
|
||||
db.frame_images[frame.id] = frame
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/shot-frame-images/1")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert 1 not in db.frame_images
|
||||
|
||||
|
||||
def test_create_shot_detail_validation_error_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeShotSubresourceDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shot-details",
|
||||
json={
|
||||
"id": "shot-invalid",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["code"] == 422
|
||||
assert body["data"] is None
|
||||
assert "camera_shot" in body["message"]
|
||||
assert "angle" in body["message"]
|
||||
assert "movement" in body["message"]
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Studio 主链路接口响应壳测试:projects / chapters / shots。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.studio import (
|
||||
Chapter,
|
||||
ChapterStatus,
|
||||
Project,
|
||||
ProjectStyle,
|
||||
ProjectVisualStyle,
|
||||
Shot,
|
||||
ShotStatus,
|
||||
)
|
||||
|
||||
|
||||
class _FakeStudioDB:
|
||||
"""最小 Studio DB 替身:仅覆盖项目/章节/镜头接口测试所需行为。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.projects: dict[str, Project] = {}
|
||||
self.chapters: dict[str, Chapter] = {}
|
||||
self.shots: dict[str, Shot] = {}
|
||||
|
||||
async def get(self, model: type, entity_id: str): # noqa: ANN001
|
||||
if model is Project:
|
||||
return self.projects.get(entity_id)
|
||||
if model is Chapter:
|
||||
return self.chapters.get(entity_id)
|
||||
if model is Shot:
|
||||
return self.shots.get(entity_id)
|
||||
return None
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
if isinstance(obj, Project):
|
||||
self.projects[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, Chapter):
|
||||
self.chapters[obj.id] = obj
|
||||
return
|
||||
if isinstance(obj, Shot):
|
||||
self.shots[obj.id] = obj
|
||||
return
|
||||
raise TypeError(f"Unsupported object type: {type(obj)!r}")
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: object) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
async def delete(self, obj: object) -> None:
|
||||
if isinstance(obj, Project):
|
||||
self.projects.pop(obj.id, None)
|
||||
return
|
||||
if isinstance(obj, Chapter):
|
||||
self.chapters.pop(obj.id, None)
|
||||
return
|
||||
if isinstance(obj, Shot):
|
||||
self.shots.pop(obj.id, None)
|
||||
return
|
||||
raise TypeError(f"Unsupported object type: {type(obj)!r}")
|
||||
|
||||
|
||||
def _override_db(db: _FakeStudioDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeStudioDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def _seed_project(db: _FakeStudioDB, project_id: str = "proj-1") -> Project:
|
||||
now = datetime.now(UTC)
|
||||
obj = Project(
|
||||
id=project_id,
|
||||
name="测试项目",
|
||||
description="项目说明",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
seed=7,
|
||||
unify_style=True,
|
||||
progress=10,
|
||||
stats={},
|
||||
)
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
db.projects[obj.id] = obj
|
||||
return obj
|
||||
|
||||
|
||||
def _seed_chapter(
|
||||
db: _FakeStudioDB,
|
||||
*,
|
||||
chapter_id: str = "ch-1",
|
||||
project_id: str = "proj-1",
|
||||
) -> Chapter:
|
||||
now = datetime.now(UTC)
|
||||
obj = Chapter(
|
||||
id=chapter_id,
|
||||
project_id=project_id,
|
||||
index=1,
|
||||
title="第一章",
|
||||
summary="章节摘要",
|
||||
raw_text="原文",
|
||||
condensed_text="精简原文",
|
||||
storyboard_count=0,
|
||||
status=ChapterStatus.draft,
|
||||
)
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
db.chapters[obj.id] = obj
|
||||
return obj
|
||||
|
||||
|
||||
def _seed_shot(
|
||||
db: _FakeStudioDB,
|
||||
*,
|
||||
shot_id: str = "shot-1",
|
||||
chapter_id: str = "ch-1",
|
||||
) -> Shot:
|
||||
now = datetime.now(UTC)
|
||||
obj = Shot(
|
||||
id=shot_id,
|
||||
chapter_id=chapter_id,
|
||||
index=1,
|
||||
title="镜头一",
|
||||
thumbnail="",
|
||||
status=ShotStatus.pending,
|
||||
script_excerpt="剧本摘录",
|
||||
generated_video_file_id=None,
|
||||
)
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
db.shots[obj.id] = obj
|
||||
return obj
|
||||
|
||||
|
||||
def test_create_project_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/projects",
|
||||
json={
|
||||
"id": "proj-create",
|
||||
"name": "新项目",
|
||||
"description": "说明",
|
||||
"style": "真人都市",
|
||||
"visual_style": "现实",
|
||||
"seed": 1,
|
||||
"unify_style": True,
|
||||
"progress": 0,
|
||||
"stats": {},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["id"] == "proj-create"
|
||||
assert body["data"]["name"] == "新项目"
|
||||
|
||||
|
||||
def test_get_project_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/projects/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Project not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_project_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
_seed_project(db, "proj-delete")
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/projects/proj-delete")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert "proj-delete" not in db.projects
|
||||
|
||||
|
||||
def test_create_chapter_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
_seed_project(db, "proj-chapter")
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/chapters",
|
||||
json={
|
||||
"id": "ch-create",
|
||||
"project_id": "proj-chapter",
|
||||
"index": 1,
|
||||
"title": "第一章",
|
||||
"summary": "摘要",
|
||||
"raw_text": "原文",
|
||||
"condensed_text": "精简原文",
|
||||
"storyboard_count": 0,
|
||||
"status": "draft",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["data"]["id"] == "ch-create"
|
||||
assert body["data"]["project_id"] == "proj-chapter"
|
||||
|
||||
|
||||
def test_create_chapter_missing_project_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/chapters",
|
||||
json={
|
||||
"id": "ch-fail",
|
||||
"project_id": "proj-missing",
|
||||
"index": 1,
|
||||
"title": "第一章",
|
||||
"summary": "",
|
||||
"raw_text": "",
|
||||
"condensed_text": "",
|
||||
"storyboard_count": 0,
|
||||
"status": "draft",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"code": 400, "message": "Project not found", "data": None}
|
||||
|
||||
|
||||
def test_get_chapter_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/chapters/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Chapter not found", "data": None}
|
||||
|
||||
|
||||
def test_create_shot_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
_seed_project(db, "proj-shot")
|
||||
_seed_chapter(db, chapter_id="ch-shot", project_id="proj-shot")
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shots",
|
||||
json={
|
||||
"id": "shot-create",
|
||||
"chapter_id": "ch-shot",
|
||||
"index": 1,
|
||||
"title": "镜头一",
|
||||
"thumbnail": "",
|
||||
"status": "pending",
|
||||
"script_excerpt": "镜头内容",
|
||||
"generated_video_file_id": None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["data"]["id"] == "shot-create"
|
||||
assert body["data"]["chapter_id"] == "ch-shot"
|
||||
|
||||
|
||||
def test_get_shot_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/studio/shots/missing")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Shot not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_shot_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
_seed_project(db, "proj-shot-delete")
|
||||
_seed_chapter(db, chapter_id="ch-shot-delete", project_id="proj-shot-delete")
|
||||
_seed_shot(db, shot_id="shot-delete", chapter_id="ch-shot-delete")
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/studio/shots/shot-delete")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert "shot-delete" not in db.shots
|
||||
|
||||
|
||||
def test_create_shot_validation_error_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeStudioDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/studio/shots",
|
||||
json={
|
||||
"id": "shot-invalid",
|
||||
"title": "缺字段镜头",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["code"] == 422
|
||||
assert body["data"] is None
|
||||
assert "chapter_id" in body["message"]
|
||||
assert "index" in body["message"]
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.studio import (
|
||||
Chapter,
|
||||
FileItem,
|
||||
FileType,
|
||||
FileUsageKind,
|
||||
Project,
|
||||
ProjectStyle,
|
||||
ProjectVisualStyle,
|
||||
Shot,
|
||||
)
|
||||
from app.schemas.studio.files import FileUpdate
|
||||
from app.services.studio.files import get_file_detail, list_files_paginated, update_file_meta
|
||||
|
||||
|
||||
async def _build_session() -> tuple[AsyncSession, object]:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
||||
session_local = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_local(), engine
|
||||
|
||||
|
||||
async def _seed_scope_graph(db: AsyncSession) -> None:
|
||||
project = Project(
|
||||
id="p1",
|
||||
name="项目一",
|
||||
description="",
|
||||
style=ProjectStyle.real_people_city,
|
||||
visual_style=ProjectVisualStyle.live_action,
|
||||
)
|
||||
chapter = Chapter(id="c1", project_id="p1", index=1, title="第一章")
|
||||
shot = Shot(id="s1", chapter_id="c1", index=1, title="镜头一")
|
||||
db.add_all([project, chapter, shot])
|
||||
await db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_files_paginated_filters_by_keyword() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
db.add_all(
|
||||
[
|
||||
FileItem(id="f1", type=FileType.image, name="角色主图", thumbnail="", tags=[], storage_key="files/a.png"),
|
||||
FileItem(id="f2", type=FileType.video, name="片段视频", thumbnail="", tags=[], storage_key="files/b.mp4"),
|
||||
]
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
resp = await list_files_paginated(
|
||||
db,
|
||||
q="角色",
|
||||
order="name",
|
||||
is_desc=False,
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
|
||||
assert resp.data is not None
|
||||
assert resp.data.pagination.total == 1
|
||||
assert [item.id for item in resp.data.items] == ["f1"]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_detail_includes_usages() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_scope_graph(db)
|
||||
db.add(
|
||||
FileItem(
|
||||
id="f1",
|
||||
type=FileType.image,
|
||||
name="角色主图",
|
||||
thumbnail="thumb",
|
||||
tags=["hero"],
|
||||
storage_key="files/a.png",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await update_file_meta(
|
||||
db,
|
||||
file_id="f1",
|
||||
body=FileUpdate(
|
||||
usage={
|
||||
"project_id": "p1",
|
||||
"chapter_id": "c1",
|
||||
"shot_id": "s1",
|
||||
"usage_kind": FileUsageKind.upload,
|
||||
"source_ref": "manual",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
detail = await get_file_detail(db, file_id="f1")
|
||||
|
||||
assert detail.id == "f1"
|
||||
assert len(detail.usages) == 1
|
||||
assert detail.usages[0].project_id == "p1"
|
||||
assert detail.usages[0].chapter_id == "c1"
|
||||
assert detail.usages[0].shot_id == "s1"
|
||||
assert detail.usages[0].usage_kind == FileUsageKind.upload
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file_meta_updates_fields_and_upserts_usage() -> None:
|
||||
db, engine = await _build_session()
|
||||
async with db:
|
||||
await _seed_scope_graph(db)
|
||||
db.add(
|
||||
FileItem(
|
||||
id="f1",
|
||||
type=FileType.image,
|
||||
name="旧名称",
|
||||
thumbnail="old",
|
||||
tags=["old"],
|
||||
storage_key="files/a.png",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
updated = await update_file_meta(
|
||||
db,
|
||||
file_id="f1",
|
||||
body=FileUpdate(
|
||||
name="新名称",
|
||||
thumbnail="new-thumb",
|
||||
tags=["hero", "poster"],
|
||||
usage={
|
||||
"project_id": "p1",
|
||||
"chapter_id": "c1",
|
||||
"shot_id": "s1",
|
||||
"usage_kind": FileUsageKind.asset_image,
|
||||
"source_ref": "slot-1",
|
||||
},
|
||||
),
|
||||
)
|
||||
updated_again = await update_file_meta(
|
||||
db,
|
||||
file_id="f1",
|
||||
body=FileUpdate(
|
||||
usage={
|
||||
"project_id": "p1",
|
||||
"chapter_id": "c1",
|
||||
"shot_id": "s1",
|
||||
"usage_kind": FileUsageKind.asset_image,
|
||||
"source_ref": "slot-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
detail = await get_file_detail(db, file_id="f1")
|
||||
|
||||
assert updated.name == "新名称"
|
||||
assert updated.thumbnail == "new-thumb"
|
||||
assert updated.tags == ["hero", "poster"]
|
||||
assert updated_again.id == "f1"
|
||||
assert len(detail.usages) == 1
|
||||
assert detail.usages[0].usage_kind == FileUsageKind.asset_image
|
||||
assert detail.usages[0].source_ref == "slot-1"
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""film/task_status 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.routes.film import task_status as task_status_route
|
||||
from app.core.task_manager.types import TaskStatus
|
||||
from app.dependencies import get_db
|
||||
from app.main import app
|
||||
from app.models.task import GenerationDeliveryMode, GenerationTask, GenerationTaskStatus
|
||||
from app.models.task_links import GenerationTaskLink, GenerationTaskLinkStatus
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class _FakeTaskDB:
|
||||
def __init__(self) -> None:
|
||||
self.tasks: dict[str, GenerationTask] = {}
|
||||
self.links: dict[int, GenerationTaskLink] = {}
|
||||
self._link_id = 1
|
||||
|
||||
async def get(self, model: type, entity_id):
|
||||
if model is GenerationTask:
|
||||
return self.tasks.get(entity_id)
|
||||
if model is GenerationTaskLink:
|
||||
return self.links.get(entity_id)
|
||||
return None
|
||||
|
||||
async def execute(self, *_args, **_kwargs):
|
||||
rows = list(self.links.values())
|
||||
return _FakeResult(rows)
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
if isinstance(obj, GenerationTaskLink):
|
||||
if getattr(obj, "id", None) is None:
|
||||
obj.id = self._link_id
|
||||
self._link_id += 1
|
||||
self.links[obj.id] = obj
|
||||
|
||||
async def flush(self) -> None:
|
||||
return None
|
||||
|
||||
async def refresh(self, obj: object) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if getattr(obj, "created_at", None) is None:
|
||||
obj.created_at = now
|
||||
obj.updated_at = now
|
||||
|
||||
async def delete(self, obj: object) -> None:
|
||||
if isinstance(obj, GenerationTaskLink):
|
||||
self.links.pop(obj.id, None)
|
||||
|
||||
|
||||
def _override_db(db: _FakeTaskDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeTaskDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def test_get_task_status_not_found_returns_api_response(client: TestClient, monkeypatch) -> None:
|
||||
class _FakeStore:
|
||||
def __init__(self, _db) -> None:
|
||||
pass
|
||||
|
||||
async def get_status_view(self, _task_id: str):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(task_status_route, "SqlAlchemyTaskStore", _FakeStore)
|
||||
db = _FakeTaskDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/film/tasks/task-missing/status")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Task not found", "data": None}
|
||||
|
||||
|
||||
def test_get_task_result_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeTaskDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/film/tasks/task-missing/result")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Task not found", "data": None}
|
||||
|
||||
|
||||
def test_create_task_link_returns_created_envelope(client: TestClient) -> None:
|
||||
db = _FakeTaskDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/task-links",
|
||||
json={
|
||||
"task_id": "task-1",
|
||||
"resource_type": "image",
|
||||
"relation_type": "prop",
|
||||
"relation_entity_id": "prop-1",
|
||||
"file_id": None,
|
||||
"status": "todo",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["id"] == 1
|
||||
assert body["data"]["task_id"] == "task-1"
|
||||
|
||||
|
||||
def test_get_task_link_not_found_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeTaskDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/film/task-links/999")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "Task link not found", "data": None}
|
||||
|
||||
|
||||
def test_delete_task_link_returns_empty_envelope(client: TestClient) -> None:
|
||||
db = _FakeTaskDB()
|
||||
link = GenerationTaskLink(
|
||||
task_id="task-1",
|
||||
resource_type="image",
|
||||
relation_type="prop",
|
||||
relation_entity_id="prop-1",
|
||||
file_id=None,
|
||||
status=GenerationTaskLinkStatus.todo,
|
||||
)
|
||||
link.id = 1
|
||||
link.created_at = datetime.now(UTC)
|
||||
link.updated_at = link.created_at
|
||||
db.links[link.id] = link
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.delete("/api/v1/film/task-links/1")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"code": 200, "message": "success", "data": None}
|
||||
assert 1 not in db.links
|
||||
|
||||
|
||||
def test_get_task_result_returns_success_envelope(client: TestClient) -> None:
|
||||
db = _FakeTaskDB()
|
||||
task = GenerationTask(
|
||||
id="task-1",
|
||||
mode=GenerationDeliveryMode.async_polling,
|
||||
status=GenerationTaskStatus.succeeded,
|
||||
progress=100,
|
||||
payload={},
|
||||
result={"url": "https://example.com/video.mp4"},
|
||||
error="",
|
||||
)
|
||||
task.created_at = datetime.now(UTC)
|
||||
task.updated_at = task.created_at
|
||||
db.tasks[task.id] = task
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
response = client.get("/api/v1/film/tasks/task-1/result")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["task_id"] == "task-1"
|
||||
assert body["data"]["status"] == TaskStatus.succeeded.value
|
||||
@@ -0,0 +1,149 @@
|
||||
"""film/tasks_images 接口响应壳测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.routes.film import tasks_images as route
|
||||
from app.dependencies import get_db, get_nothinking_llm
|
||||
from app.main import app
|
||||
|
||||
|
||||
class _FakeTaskRecord:
|
||||
def __init__(self, task_id: str) -> None:
|
||||
self.id = task_id
|
||||
|
||||
|
||||
class _FakeTaskManager:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def create(self, *_args, **_kwargs) -> _FakeTaskRecord:
|
||||
return _FakeTaskRecord("prompt-task-1")
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
|
||||
def add(self, obj: object) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
|
||||
class _DummyLLM:
|
||||
pass
|
||||
|
||||
|
||||
def _close_task_stub(coro):
|
||||
coro.close()
|
||||
return None
|
||||
|
||||
|
||||
def _override_db(db: _FakeDB):
|
||||
async def _get_db() -> AsyncGenerator[_FakeDB, None]:
|
||||
yield db
|
||||
|
||||
return _get_db
|
||||
|
||||
|
||||
def _override_llm() -> _DummyLLM:
|
||||
return _DummyLLM()
|
||||
|
||||
|
||||
def test_create_shot_frame_prompt_task_returns_created_envelope(client: TestClient, monkeypatch) -> None:
|
||||
db = _FakeDB()
|
||||
|
||||
async def _fake_build(*_args, **_kwargs):
|
||||
return {"shot_id": "shot-1", "frame_type": "first"}
|
||||
|
||||
monkeypatch.setattr(route, "build_shot_frame_prompt_run_args", _fake_build)
|
||||
monkeypatch.setattr(route, "TaskManager", _FakeTaskManager)
|
||||
monkeypatch.setattr(route.asyncio, "create_task", _close_task_stub)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
app.dependency_overrides[get_nothinking_llm] = _override_llm
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/shot-frame-prompts",
|
||||
json={"shot_id": "shot-1", "frame_type": "first"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["code"] == 201
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["task_id"] == "prompt-task-1"
|
||||
assert len(db.added) == 1
|
||||
|
||||
|
||||
def test_create_shot_frame_prompt_task_invalid_frame_type_returns_api_response(
|
||||
client: TestClient, monkeypatch
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
|
||||
def _fake_normalize(_frame_type: str) -> str:
|
||||
raise HTTPException(status_code=400, detail="frame_type must be one of: first/last/key")
|
||||
|
||||
monkeypatch.setattr(route, "normalize_frame_type", _fake_normalize)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
app.dependency_overrides[get_nothinking_llm] = _override_llm
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/shot-frame-prompts",
|
||||
json={"shot_id": "shot-1", "frame_type": "middle"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"code": 400,
|
||||
"message": "frame_type must be one of: first/last/key",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def test_create_shot_frame_prompt_task_missing_shot_detail_returns_api_response(
|
||||
client: TestClient, monkeypatch
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
|
||||
async def _fake_build(*_args, **_kwargs):
|
||||
raise HTTPException(status_code=404, detail="ShotDetail not found")
|
||||
|
||||
monkeypatch.setattr(route, "build_shot_frame_prompt_run_args", _fake_build)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
app.dependency_overrides[get_nothinking_llm] = _override_llm
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/shot-frame-prompts",
|
||||
json={"shot_id": "shot-1", "frame_type": "first"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"code": 404, "message": "ShotDetail not found", "data": None}
|
||||
|
||||
|
||||
def test_create_shot_frame_prompt_task_validation_error_returns_api_response(client: TestClient) -> None:
|
||||
db = _FakeDB()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
app.dependency_overrides[get_nothinking_llm] = _override_llm
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/film/tasks/shot-frame-prompts",
|
||||
json={"frame_type": "first"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["code"] == 422
|
||||
assert body["data"] is None
|
||||
assert "shot_id" in body["message"]
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
"""仓库根级 pytest 入口适配。
|
||||
|
||||
作用:
|
||||
- 从仓库根目录运行 pytest 时,自动将 backend/ 加入 sys.path
|
||||
- 避免只能在 backend/ 目录内执行测试配置才生效
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent / "backend"
|
||||
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""根目录 pytest 运行时补齐 asyncio marker。"""
|
||||
config.addinivalue_line("markers", "asyncio: mark test as asyncio coroutine")
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
||||
"""在仓库根目录运行时,为 async 测试提供轻量执行器。
|
||||
|
||||
说明:
|
||||
- backend/ 目录下通常会使用更完整的测试环境;
|
||||
- 根目录 `uv run pytest backend/tests ...` 时,可能没有安装 pytest-asyncio;
|
||||
- 这里用 asyncio.run 兜底执行 coroutine test,避免 async 用例被跳过。
|
||||
"""
|
||||
|
||||
if not inspect.iscoroutinefunction(pyfuncitem.obj):
|
||||
return None
|
||||
|
||||
funcargs = {
|
||||
arg: pyfuncitem.funcargs[arg]
|
||||
for arg in pyfuncitem._fixtureinfo.argnames
|
||||
if arg in pyfuncitem.funcargs
|
||||
}
|
||||
asyncio.run(pyfuncitem.obj(**funcargs))
|
||||
return True
|
||||
Generated
+3651
-3111
File diff suppressed because it is too large.
Load diff
+4
-3
@@ -1,3 +1,4 @@
|
||||
// 本地开发:占位文件,避免 /env.js 404;实际后端地址可用 VITE_BACKEND_URL。
|
||||
// 容器部署时由 Nginx/入口脚本覆盖为:window.__ENV = { BACKEND_URL: "https://..." }
|
||||
window.__ENV = window.__ENV || {}
|
||||
// 本地开发默认直连本机后端;容器部署时由 Nginx/入口脚本覆盖。
|
||||
window.__ENV = window.__ENV || {
|
||||
BACKEND_URL: 'http://localhost:8000',
|
||||
}
|
||||
@@ -30,9 +30,10 @@ export function resolveAssetUrl(value?: string | null): string | undefined {
|
||||
|
||||
try {
|
||||
const fallbackBase =
|
||||
window.__ENV?.BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost')
|
||||
'http://localhost:8000'
|
||||
return new URL(trimmed, OpenAPI.BASE || fallbackBase).toString()
|
||||
} catch {
|
||||
return trimmed
|
||||
@@ -43,4 +44,3 @@ export function buildFileDownloadUrl(fileId?: string | null): string | undefined
|
||||
if (!fileId) return undefined
|
||||
return resolveAssetUrl(`/api/v1/studio/files/${encodeURIComponent(fileId)}/download`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL ?? '/api'
|
||||
const backendBaseUrl = import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:8000'
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL ?? `${backendBaseUrl}/api`
|
||||
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL,
|
||||
@@ -37,4 +38,3 @@ export const del = <T = unknown>(url: string, config?: AxiosRequestConfig): Prom
|
||||
http.delete<T>(url, config) as Promise<T>
|
||||
|
||||
export default http
|
||||
|
||||
@@ -13,7 +13,7 @@ declare global {
|
||||
*
|
||||
* 说明:
|
||||
* - 生成接口的路径已包含 `/api/v1/...`,因此 BASE 默认应为空串(同源)或完整后端地址。
|
||||
* - 如需直连本地后端,可在应用启动时调用 `initOpenAPI('http://127.0.0.1:8000')`。
|
||||
* - 本地开发默认直连 `http://localhost:8000`。
|
||||
*/
|
||||
export function initOpenAPI(base: string = '') {
|
||||
OpenAPI.BASE = base
|
||||
@@ -21,5 +21,6 @@ export function initOpenAPI(base: string = '') {
|
||||
|
||||
const runtimeBackendUrl = window.__ENV?.BACKEND_URL
|
||||
const buildtimeBackendUrl = import.meta.env.VITE_BACKEND_URL
|
||||
initOpenAPI(runtimeBackendUrl ?? buildtimeBackendUrl ?? '')
|
||||
const defaultBackendUrl = 'http://localhost:8000'
|
||||
|
||||
initOpenAPI(runtimeBackendUrl ?? buildtimeBackendUrl ?? defaultBackendUrl)
|
||||
@@ -0,0 +1,5 @@
|
||||
[pytest]
|
||||
testpaths = backend/tests
|
||||
markers =
|
||||
asyncio: mark test as asyncio coroutine
|
||||
integration: mark test as integration (e.g. real LLM); run with -m integration when OPENAI_API_KEY is set
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
title: "后端重构阶段总结:路由瘦身、Service 分层与测试补强"
|
||||
date: 2026-04-03
|
||||
description: "Jellyfish 后端第一阶段工程化整理总结,包括 route -> service 重构、响应壳统一、错误文案统一与测试体系补强。"
|
||||
---
|
||||
|
||||
这一阶段的后端整理,重点不是继续堆功能,而是把已经基本稳定的主流程真正收成一套**可维护、可验证、可继续演进**的结构。
|
||||
|
||||
如果用一句话概括,这一轮完成的是:
|
||||
|
||||
- **把胖路由拆薄**
|
||||
- **把核心业务下沉到 service**
|
||||
- **把接口返回和错误风格收拢**
|
||||
- **把测试入口和回归护栏补齐**
|
||||
|
||||
## 这轮为什么要做
|
||||
|
||||
在整理开始之前,后端虽然已经具备比较完整的业务能力,但也有几个很典型的问题:
|
||||
|
||||
- 部分 API 路由承载了过多业务逻辑
|
||||
- 通用校验、CRUD、错误文案存在大量重复
|
||||
- 成功响应、删除响应、异常响应的风格不够一致
|
||||
- 一些复杂链路已经能用,但缺少足够测试护栏
|
||||
|
||||
这些问题在原型阶段可以接受,但如果继续往上叠功能,后续维护成本会越来越高。
|
||||
|
||||
所以这一阶段的目标很明确:
|
||||
|
||||
- 先把结构理顺
|
||||
- 先把边界厘清
|
||||
- 先把验证体系补上
|
||||
|
||||
## 结构上发生了什么变化
|
||||
|
||||
### 1. Route 更薄了
|
||||
|
||||
`app/api/v1/routes/` 现在更明确地只做这些事:
|
||||
|
||||
- 接收参数
|
||||
- 依赖注入
|
||||
- 调 service
|
||||
- 包装 `ApiResponse`
|
||||
|
||||
复杂业务规则、跨实体校验、任务编排、文件与存储处理,已经不再继续堆在 route 文件里。
|
||||
|
||||
### 2. Common 层建立起来了
|
||||
|
||||
这一轮补了 `services/common/`:
|
||||
|
||||
- `validators.py`
|
||||
- `crud.py`
|
||||
- `errors.py`
|
||||
|
||||
这一层的作用很直接:
|
||||
|
||||
- 收掉高频样板代码
|
||||
- 统一错误风格
|
||||
- 减少 route 与 service 里的重复实现
|
||||
|
||||
### 3. Studio 主链路开始成型
|
||||
|
||||
`services/studio/` 现在已经承接了 Studio 侧大部分核心逻辑,包括:
|
||||
|
||||
- `shots`
|
||||
- `shot_assets`
|
||||
- `shot_details`
|
||||
- `shot_dialogs`
|
||||
- `shot_frames`
|
||||
- `shot_character_links`
|
||||
- `script_division`
|
||||
- `files`
|
||||
- `image_task_validation`
|
||||
- `image_task_references`
|
||||
- `image_task_prompts`
|
||||
- `image_task_runner`
|
||||
|
||||
这意味着:
|
||||
|
||||
- 分镜主链路
|
||||
- 文件链路
|
||||
- 图片任务链路
|
||||
|
||||
都已经从“胖路由”逐步收成了“路由入口 + service 实现”的结构。
|
||||
|
||||
### 4. LLM / Film 侧也一起收了
|
||||
|
||||
这轮没有只处理 Studio,还顺手把另外两条明显偏胖的链路也收了一轮:
|
||||
|
||||
- `services/llm/manage.py`
|
||||
- `services/film/generated_video.py`
|
||||
- `services/film/shot_frame_prompt_tasks.py`
|
||||
|
||||
也就是说,这一轮不是局部美化,而是把后端里最明显的几块结构债一起往前推了一步。
|
||||
|
||||
## `entities.py` 这块也做了拆分
|
||||
|
||||
`entities.py` 原来是比较典型的大文件技术债。
|
||||
|
||||
这一轮把它逐步拆成了下面几层:
|
||||
|
||||
- `entity_crud.py`
|
||||
- `entity_images.py`
|
||||
- `entity_existence.py`
|
||||
- `entity_specs.py`
|
||||
- `entity_thumbnails.py`
|
||||
- `entities.py`(协调层)
|
||||
|
||||
现在 `entities.py` 已经不再自己承载全部实现,而是开始更像一个协调入口。
|
||||
|
||||
这对后续继续维护:
|
||||
|
||||
- 主资源 CRUD
|
||||
- 图片 CRUD
|
||||
- 名称存在性检测
|
||||
|
||||
都会轻松很多。
|
||||
|
||||
## 接口规范做了哪些统一
|
||||
|
||||
### 响应壳统一
|
||||
|
||||
这一轮已经把后端接口成功响应统一到了 `ApiResponse`:
|
||||
|
||||
- 创建成功:`created_response(...)`
|
||||
- 普通成功:`success_response(...)`
|
||||
- 空响应:`empty_response()`
|
||||
- 分页成功:`paginated_response(...)`
|
||||
|
||||
这样做的好处很直接:
|
||||
|
||||
- 创建接口不再各写各的 `201`
|
||||
- 删除接口不再混用 `204` 和空 body
|
||||
- 前端消费方式更稳定
|
||||
|
||||
### 错误文案统一
|
||||
|
||||
这一轮新增并逐步接入了这些公共错误模板:
|
||||
|
||||
- `entity_not_found(...)`
|
||||
- `entity_already_exists(...)`
|
||||
- `required_field(...)`
|
||||
- `invalid_choice(...)`
|
||||
- `not_belong_to(...)`
|
||||
- `relation_mismatch(...)`
|
||||
|
||||
当然,少量带上下文信息、对排障有帮助的错误文案还是保留了手写形式,没有强行抽象。
|
||||
|
||||
## 测试这轮补到了什么程度
|
||||
|
||||
这轮整理里,测试不是最后补的,而是跟着重构同步往前推的。
|
||||
|
||||
### Service 层测试
|
||||
|
||||
已经覆盖到:
|
||||
|
||||
- common helpers
|
||||
- llm 管理
|
||||
- 镜头角色关联
|
||||
- 剧本分镜写库
|
||||
- 文件服务
|
||||
- 视频生成
|
||||
- 分镜帧提示词任务
|
||||
- `image_task_*`
|
||||
|
||||
### API 层测试
|
||||
|
||||
已经覆盖到:
|
||||
|
||||
- `prompts`
|
||||
- `llm/providers`
|
||||
- `projects / chapters / shots`
|
||||
- `shot-details / shot-dialog-lines / shot-frame-images`
|
||||
- `files`
|
||||
- `task_status`
|
||||
- `shot_character_links`
|
||||
- `image_tasks`
|
||||
- `generated_video`
|
||||
- `tasks_images`
|
||||
- `entities`
|
||||
|
||||
### 当前结果
|
||||
|
||||
这一轮结束时,从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
uv run pytest backend/tests -q
|
||||
```
|
||||
|
||||
结果为:
|
||||
|
||||
```text
|
||||
125 passed
|
||||
```
|
||||
|
||||
而且根目录与 `backend/` 目录两种入口都已经可以稳定运行,不再出现 async 用例被误跳过的假通过问题。
|
||||
|
||||
## 这轮的直接收益
|
||||
|
||||
这轮整理最直接的收益有四个:
|
||||
|
||||
### 1. 代码更容易读了
|
||||
|
||||
路由文件终于更像路由,service 文件也开始有明确边界。
|
||||
|
||||
### 2. 新增功能更容易落位
|
||||
|
||||
后面再加逻辑时,更容易判断:
|
||||
|
||||
- 哪些留在 route
|
||||
- 哪些放到 service
|
||||
- 哪些抽到 common
|
||||
|
||||
### 3. 前后端接口协作更稳定
|
||||
|
||||
响应壳和错误风格统一后,前端处理成功/失败返回的成本会更低。
|
||||
|
||||
### 4. 后续迭代更有安全感
|
||||
|
||||
测试护栏已经立起来了,后面继续整理或继续做功能时,回归风险会小很多。
|
||||
|
||||
## 当前还保留的技术债
|
||||
|
||||
这一轮不是终局,仍然有一些刻意保留或尚未完全收口的点:
|
||||
|
||||
- `dependencies.py` 里还有一批直接手写的 `HTTPException`
|
||||
- 少量错误文案仍然保留了上下文写法
|
||||
- 个别 route / service 的命名还可以继续统一
|
||||
- `files.py` 里还保留了少量中文错误文案
|
||||
|
||||
另外,`entities.py` 虽然已经拆成了:
|
||||
|
||||
- `entity_crud.py`
|
||||
- `entity_images.py`
|
||||
- `entity_existence.py`
|
||||
- `entity_specs.py`
|
||||
- `entity_thumbnails.py`
|
||||
- `entities.py`(协调层)
|
||||
|
||||
但它依然属于相对复杂的“通用实体服务”,后续还可以继续:
|
||||
|
||||
- 降低单文件体积
|
||||
- 提升类型标注精度
|
||||
- 继续收敛内部命名风格
|
||||
|
||||
不过这些已经不是“结构阻塞项”了,更像第二轮清理项。
|
||||
|
||||
## 下一步会做什么
|
||||
|
||||
在这一轮完成之后,后续更适合进入下面两个方向之一:
|
||||
|
||||
### 1. 回到产品侧优化
|
||||
|
||||
也就是最开始规划里的重点:
|
||||
|
||||
- 交互性优化
|
||||
- 提示词优化
|
||||
- 剪辑功能补强
|
||||
|
||||
### 2. 继续做低风险工程化清理
|
||||
|
||||
例如:
|
||||
|
||||
- 第二轮文案与命名统一
|
||||
- 剩余历史文件拆分
|
||||
- 补更多细粒度测试
|
||||
|
||||
整体判断是:
|
||||
|
||||
**后端已经从“原型堆叠阶段”进入“可持续维护阶段”了。**
|
||||
|
||||
这也是这一轮最重要的成果。
|
||||
|
||||
## 这一轮的阶段性结论
|
||||
|
||||
如果把这一轮的成果再压缩成一句话,它真正完成的是:
|
||||
|
||||
- **分层变清楚了**
|
||||
- **接口变统一了**
|
||||
- **错误风格开始收敛了**
|
||||
- **测试护栏补起来了**
|
||||
|
||||
这意味着后续不管你是继续做:
|
||||
|
||||
- 数据模型标准化
|
||||
- 交互性优化
|
||||
- 提示词优化
|
||||
- 剪辑相关能力补强
|
||||
|
||||
后端都已经有了一个更稳的基础。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
接下来更适合切换到两类工作之一:
|
||||
|
||||
### 1. 产品向优化
|
||||
|
||||
- 交互性优化
|
||||
- 提示词优化
|
||||
- 剪辑能力补强
|
||||
|
||||
### 2. 第二轮工程化清理
|
||||
|
||||
- 继续拆剩余历史大文件
|
||||
- 收敛少量残留错误文案和命名差异
|
||||
- 补更多细粒度测试
|
||||
|
||||
如果按当前收益来看,下一步最值得优先切到:
|
||||
|
||||
**交互性优化。**
|
||||
@@ -12,3 +12,7 @@ description: "从项目结构到前后端分层,再到 AI 工作流的开发
|
||||
- [OpenAPI](/docs/guide/openapi/)
|
||||
- [AI 工作流](/docs/guide/ai-workflow/)
|
||||
- [开发规划](/docs/guide/development-plan/)
|
||||
|
||||
相关发布与阶段记录:
|
||||
|
||||
- [后端重构阶段总结:路由瘦身、Service 分层与测试补强](/blog/backend-refactor-phase-1/)
|
||||
@@ -5,6 +5,7 @@ description: "理解后端路由主轴、分层结构与服务定位。"
|
||||
---
|
||||
|
||||
后端位于 `backend/`,使用 FastAPI、SQLAlchemy、LangChain / LangGraph 构建。
|
||||
当前已经完成一轮“**路由层瘦身 + service 分层**”整理,整体结构比早期原型期更清晰。
|
||||
|
||||
核心分层:
|
||||
|
||||
@@ -28,3 +29,34 @@ description: "理解后端路由主轴、分层结构与服务定位。"
|
||||
|
||||
`app/services/` 负责承接业务逻辑,而不是把复杂逻辑全部塞进路由中。
|
||||
像分镜草稿拼装、图片任务构建、模型解析等能力,都已经下沉到了 service 层。
|
||||
|
||||
当前可以按下面的方式理解:
|
||||
|
||||
- `services/common`
|
||||
- 通用校验、通用 CRUD、公共错误文案
|
||||
- `services/studio`
|
||||
- 项目、章节、分镜、文件、图片任务等 Studio 主链路
|
||||
- `services/llm`
|
||||
- Provider、Model、Settings 管理
|
||||
- `services/film`
|
||||
- 视频生成、分镜帧提示词任务等编排逻辑
|
||||
|
||||
## 响应与测试
|
||||
|
||||
后端接口当前统一使用 `ApiResponse` 响应壳,并约定:
|
||||
|
||||
- 创建:`created_response(...)`
|
||||
- 普通成功:`success_response(...)`
|
||||
- 空响应:`empty_response()`
|
||||
- 分页:`paginated_response(...)`
|
||||
|
||||
测试入口也已经统一,既可以在 `backend/` 目录执行,也可以在仓库根目录执行。
|
||||
|
||||
推荐命令:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
如果只做快速验证,也可以优先运行接口响应壳测试与 service 层测试。
|
||||
Reference in new issue
Block a user