[refactor] 重构 backend 分层并补齐测试与文档

This commit is contained in:
extreme committed 2026-04-03 14:08:15 +08:00
1 parent ae85d0e0ca
commit e33690d2ba
88 files changed
+12269 -6519

No files matched your search

+129 -4
View File
@@ -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`
## 扩展说明
+4 -4
View File
@@ -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")
+15 -345
View File
@@ -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))
+10 -10
View File
@@ -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()
+19 -118
View File
@@ -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))
+51 -177
View File
@@ -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))
+7 -43
View File
@@ -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)}"
)
+41 -33
View File
@@ -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()
+5 -6
View File
@@ -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()
+31 -187
View File
@@ -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
+26 -27
View File
@@ -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()
+9 -11
View File
@@ -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))
+110 -479
View File
@@ -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()
+18
View File
@@ -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],
*,
+7 -11
View File
@@ -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
+11 -17
View File
@@ -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
+7 -12
View File
@@ -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)
+3 -4
View File
@@ -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
+6 -10
View File
@@ -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)
+5 -8
View File
@@ -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
+3 -4
View File
@@ -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
+13 -22
View File
@@ -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)")
+39
View File
@@ -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",
]
+57
View File
@@ -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()
+37
View File
@@ -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}"
+59
View File
@@ -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)
+41
View File
@@ -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()
+28 -1
View File
@@ -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",
]
+257
View File
@@ -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)
+3 -3
View File
@@ -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)
+134 -5
View File
@@ -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);在某些轻量环境中可能未安装。
+85 -767
View File
@@ -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,
)
+217
View File
@@ -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()
+138
View File
@@ -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()
}
+239
View File
@@ -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
+2 -2
View File
@@ -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()
+297
View File
@@ -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)
+100
View File
@@ -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)
+105
View File
@@ -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 -7
View File
@@ -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
+1
View File
@@ -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",
]
+24
View File
@@ -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"]
+104
View File
@@ -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,
}
+124
View File
@@ -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()
+328
View File
@@ -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"]
+141
View File
@@ -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"]
+180
View File
@@ -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()
+108
View File
@@ -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()
+126
View File
@@ -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"]
+344
View File
@@ -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"]
+167
View File
@@ -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
View File
@@ -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
+3651 -3111
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -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',
}
+2 -2
View File
@@ -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`)
}
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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)
+5
View File
@@ -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. 第二轮工程化清理
- 继续拆剩余历史大文件
- 收敛少量残留错误文案和命名差异
- 补更多细粒度测试
如果按当前收益来看,下一步最值得优先切到:
**交互性优化。**
+4
View File
@@ -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/)
+32
View File
@@ -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 层测试。