From e33690d2ba44b0bc27df3c43aa9bccdbd6f7dbe1 Mon Sep 17 00:00:00 2001 From: extreme Date: Fri, 3 Apr 2026 14:05:02 +0800 Subject: [PATCH] =?UTF-8?q?[refactor]=20=E9=87=8D=E6=9E=84=20backend=20?= =?UTF-8?q?=E5=88=86=E5=B1=82=E5=B9=B6=E8=A1=A5=E9=BD=90=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/README.md | 133 +- backend/app/api/v1/routes/film/common.py | 8 +- .../app/api/v1/routes/film/generated_video.py | 360 +- backend/app/api/v1/routes/film/task_status.py | 20 +- .../app/api/v1/routes/film/tasks_images.py | 137 +- backend/app/api/v1/routes/llm.py | 228 +- .../app/api/v1/routes/script_processing.py | 50 +- backend/app/api/v1/routes/studio/chapters.py | 74 +- backend/app/api/v1/routes/studio/entities.py | 11 +- backend/app/api/v1/routes/studio/files.py | 218 +- .../app/api/v1/routes/studio/image_tasks.py | 1073 +-- backend/app/api/v1/routes/studio/projects.py | 53 +- backend/app/api/v1/routes/studio/prompts.py | 20 +- .../v1/routes/studio/shot_character_links.py | 67 +- backend/app/api/v1/routes/studio/shots.py | 589 +- backend/app/schemas/common.py | 18 + backend/app/schemas/llm.py | 18 +- backend/app/schemas/studio/assets.py | 28 +- backend/app/schemas/studio/cast.py | 19 +- backend/app/schemas/studio/cast_images.py | 7 +- backend/app/schemas/studio/files.py | 16 +- backend/app/schemas/studio/projects.py | 13 +- backend/app/schemas/studio/prompts.py | 7 +- backend/app/schemas/studio/shots.py | 35 +- backend/app/services/common/__init__.py | 39 + backend/app/services/common/crud.py | 57 + backend/app/services/common/errors.py | 37 + backend/app/services/common/validators.py | 59 + backend/app/services/film/__init__.py | 41 + backend/app/services/film/generated_video.py | 353 + .../services/film/shot_frame_prompt_tasks.py | 133 + backend/app/services/llm/__init__.py | 29 +- backend/app/services/llm/manage.py | 257 + backend/app/services/llm/resolver.py | 6 +- backend/app/services/studio/__init__.py | 139 +- backend/app/services/studio/entities.py | 852 +-- backend/app/services/studio/entity_crud.py | 217 + .../app/services/studio/entity_existence.py | 290 + backend/app/services/studio/entity_images.py | 124 + backend/app/services/studio/entity_specs.py | 138 + .../app/services/studio/entity_thumbnails.py | 78 + backend/app/services/studio/files.py | 239 + .../app/services/studio/image_task_prompts.py | 418 + .../services/studio/image_task_references.py | 152 + .../app/services/studio/image_task_runner.py | 298 + .../services/studio/image_task_validation.py | 116 + backend/app/services/studio/image_tasks.py | 4 +- .../app/services/studio/script_division.py | 63 + backend/app/services/studio/shot_assets.py | 297 + .../services/studio/shot_character_links.py | 74 + backend/app/services/studio/shot_details.py | 99 + backend/app/services/studio/shot_dialogs.py | 100 + .../services/studio/shot_extraction_draft.py | 7 +- backend/app/services/studio/shot_frames.py | 82 + backend/app/services/studio/shots.py | 105 + backend/app/utils/project_links.py | 14 +- backend/pyproject.toml | 1 + backend/tests/conftest.py | 24 + backend/tests/test_api_response_envelopes.py | 151 + backend/tests/test_common_services.py | 104 + backend/tests/test_entities_api_responses.py | 149 + .../test_entity_existence_api_responses.py | 109 + backend/tests/test_files_api_responses.py | 124 + .../test_generated_video_api_responses.py | 162 + backend/tests/test_generated_video_service.py | 160 + backend/tests/test_image_task_services.py | 328 + .../tests/test_image_tasks_api_responses.py | 114 + backend/tests/test_llm_api_responses.py | 141 + backend/tests/test_llm_manage.py | 180 + backend/tests/test_script_division.py | 108 + backend/tests/test_shot_character_links.py | 126 + ...test_shot_character_links_api_responses.py | 116 + .../test_shot_frame_prompt_tasks_service.py | 124 + .../test_shot_subresource_api_responses.py | 412 + backend/tests/test_studio_api_responses.py | 344 + backend/tests/test_studio_files_service.py | 167 + .../tests/test_task_status_api_responses.py | 196 + .../tests/test_tasks_images_api_responses.py | 149 + conftest.py | 48 + front/pnpm-lock.yaml | 6762 +++++++++-------- front/public/env.js | 7 +- front/src/pages/aiStudio/assets/utils.ts | 4 +- front/src/services/http.ts | 4 +- front/src/services/openapi.ts | 5 +- pytest.ini | 5 + site/content/blog/backend-refactor-phase-1.md | 309 + site/content/docs/guide/_index.md | 4 + site/content/docs/guide/backend.md | 32 + 88 files changed, 12269 insertions(+), 6519 deletions(-) create mode 100644 backend/app/services/common/__init__.py create mode 100644 backend/app/services/common/crud.py create mode 100644 backend/app/services/common/errors.py create mode 100644 backend/app/services/common/validators.py create mode 100644 backend/app/services/film/__init__.py create mode 100644 backend/app/services/film/generated_video.py create mode 100644 backend/app/services/film/shot_frame_prompt_tasks.py create mode 100644 backend/app/services/llm/manage.py create mode 100644 backend/app/services/studio/entity_crud.py create mode 100644 backend/app/services/studio/entity_existence.py create mode 100644 backend/app/services/studio/entity_images.py create mode 100644 backend/app/services/studio/entity_specs.py create mode 100644 backend/app/services/studio/entity_thumbnails.py create mode 100644 backend/app/services/studio/files.py create mode 100644 backend/app/services/studio/image_task_prompts.py create mode 100644 backend/app/services/studio/image_task_references.py create mode 100644 backend/app/services/studio/image_task_runner.py create mode 100644 backend/app/services/studio/image_task_validation.py create mode 100644 backend/app/services/studio/script_division.py create mode 100644 backend/app/services/studio/shot_assets.py create mode 100644 backend/app/services/studio/shot_character_links.py create mode 100644 backend/app/services/studio/shot_details.py create mode 100644 backend/app/services/studio/shot_dialogs.py create mode 100644 backend/app/services/studio/shot_frames.py create mode 100644 backend/app/services/studio/shots.py create mode 100644 backend/tests/test_api_response_envelopes.py create mode 100644 backend/tests/test_common_services.py create mode 100644 backend/tests/test_entities_api_responses.py create mode 100644 backend/tests/test_entity_existence_api_responses.py create mode 100644 backend/tests/test_files_api_responses.py create mode 100644 backend/tests/test_generated_video_api_responses.py create mode 100644 backend/tests/test_generated_video_service.py create mode 100644 backend/tests/test_image_task_services.py create mode 100644 backend/tests/test_image_tasks_api_responses.py create mode 100644 backend/tests/test_llm_api_responses.py create mode 100644 backend/tests/test_llm_manage.py create mode 100644 backend/tests/test_script_division.py create mode 100644 backend/tests/test_shot_character_links.py create mode 100644 backend/tests/test_shot_character_links_api_responses.py create mode 100644 backend/tests/test_shot_frame_prompt_tasks_service.py create mode 100644 backend/tests/test_shot_subresource_api_responses.py create mode 100644 backend/tests/test_studio_api_responses.py create mode 100644 backend/tests/test_studio_files_service.py create mode 100644 backend/tests/test_task_status_api_responses.py create mode 100644 backend/tests/test_tasks_images_api_responses.py create mode 100644 conftest.py create mode 100644 pytest.ini create mode 100644 site/content/blog/backend-refactor-phase-1.md diff --git a/backend/README.md b/backend/README.md index 9f1e5c7..ee083f0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 ` | 添加依赖 | | `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 ` | 快速做语法检查 | ## 代码检查(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` ## 扩展说明 diff --git a/backend/app/api/v1/routes/film/common.py b/backend/app/api/v1/routes/film/common.py index c592d51..70ebf69 100644 --- a/backend/app/api/v1/routes/film/common.py +++ b/backend/app/api/v1/routes/film/common.py @@ -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") - diff --git a/backend/app/api/v1/routes/film/generated_video.py b/backend/app/api/v1/routes/film/generated_video.py index 53a83b0..073e5e5 100644 --- a/backend/app/api/v1/routes/film/generated_video.py +++ b/backend/app/api/v1/routes/film/generated_video.py @@ -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)) diff --git a/backend/app/api/v1/routes/film/task_status.py b/backend/app/api/v1/routes/film/task_status.py index 46226b5..ed3f438 100644 --- a/backend/app/api/v1/routes/film/task_status.py +++ b/backend/app/api/v1/routes/film/task_status.py @@ -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() diff --git a/backend/app/api/v1/routes/film/tasks_images.py b/backend/app/api/v1/routes/film/tasks_images.py index 05c2ffd..5edfc71 100644 --- a/backend/app/api/v1/routes/film/tasks_images.py +++ b/backend/app/api/v1/routes/film/tasks_images.py @@ -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)) diff --git a/backend/app/api/v1/routes/llm.py b/backend/app/api/v1/routes/llm.py index b1857de..ad5b60a 100644 --- a/backend/app/api/v1/routes/llm.py +++ b/backend/app/api/v1/routes/llm.py @@ -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)) - diff --git a/backend/app/api/v1/routes/script_processing.py b/backend/app/api/v1/routes/script_processing.py index e11e8ba..f09abcf 100644 --- a/backend/app/api/v1/routes/script_processing.py +++ b/backend/app/api/v1/routes/script_processing.py @@ -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)}" ) - diff --git a/backend/app/api/v1/routes/studio/chapters.py b/backend/app/api/v1/routes/studio/chapters.py index 052dbae..4000045 100644 --- a/backend/app/api/v1/routes/studio/chapters.py +++ b/backend/app/api/v1/routes/studio/chapters.py @@ -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() diff --git a/backend/app/api/v1/routes/studio/entities.py b/backend/app/api/v1/routes/studio/entities.py index cb70ae3..fcbfcee 100644 --- a/backend/app/api/v1/routes/studio/entities.py +++ b/backend/app/api/v1/routes/studio/entities.py @@ -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() diff --git a/backend/app/api/v1/routes/studio/files.py b/backend/app/api/v1/routes/studio/files.py index fe12d95..2e81f77 100644 --- a/backend/app/api/v1/routes/studio/files.py +++ b/backend/app/api/v1/routes/studio/files.py @@ -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() diff --git a/backend/app/api/v1/routes/studio/image_tasks.py b/backend/app/api/v1/routes/studio/image_tasks.py index 8ea4cd7..cefede3 100644 --- a/backend/app/api/v1/routes/studio/image_tasks.py +++ b/backend/app/api/v1/routes/studio/image_tasks.py @@ -6,69 +6,36 @@ from __future__ import annotations 将任务与上层业务实体(演员形象/道具/场景/服装/角色/镜头分镜帧)建立关联。 """ -import base64 -import mimetypes - from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload -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 ImageGenerationInput, ImageGenerationResult, ImageGenerationTask, ProviderConfig from app.dependencies import get_db from app.models.studio import ( - Actor, - ActorImage, - AssetViewAngle, - AssetQualityLevel, - Character, - CharacterImage, - Chapter, - Costume, - CostumeImage, - FileItem, - PromptCategory, - Prop, - PropImage, - Scene, - SceneImage, - Shot, ShotDetail, - ShotCharacterLink, ShotFrameType, ShotFrameImage, ) -from app.models.task_links import GenerationTaskLink -from app.models.types import FileUsageKind -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.schemas.common import ApiResponse, success_response +from app.schemas.common import ApiResponse, created_response, success_response from app.schemas.studio.shots import ShotLinkedAssetItem -from app.api.v1.routes.film.common import TaskCreated, _CreateOnlyTask -from app.services.studio.image_tasks import ( - asset_prompt_category as _asset_prompt_category, - build_prompt_with_template as _build_prompt_with_template, - is_front_view as _is_front_view, - load_provider_config as _load_provider_config, - map_view_angle_for_prompt as _map_view_angle_for_prompt, - resolve_front_image_ref as _resolve_front_image_ref, - resolve_image_model as _resolve_image_model, - resolve_ordered_image_refs as _resolve_ordered_image_refs, - shot_frame_prompt_category as _shot_frame_prompt_category, +from app.api.v1.routes.film.common import TaskCreated +from app.services.studio.image_task_references import ( + resolve_reference_file_ids_and_names_from_linked_items as _resolve_reference_file_ids_and_names_from_linked_items_service, + resolve_reference_image_refs_by_file_ids as _resolve_reference_image_refs_by_file_ids_service, +) +from app.services.studio.image_task_prompts import ( + build_actor_prompt_and_refs as _build_actor_prompt_and_refs_service, + build_asset_prompt_and_refs as _build_asset_prompt_and_refs_service, + build_character_prompt_and_refs as _build_character_prompt_and_refs_service, + build_shot_frame_prompt_and_refs as _build_shot_frame_prompt_and_refs_service, +) +from app.services.studio.image_task_runner import create_image_task_and_link as _create_image_task_and_link_service +from app.services.studio.image_task_validation import ( + validate_actor_image as _validate_actor_image_service, + validate_asset_image_and_relation_type as _validate_asset_image_and_relation_type_service, + validate_character_image as _validate_character_image_service, ) -from app.utils.files import create_file_from_url_or_b64 router = APIRouter() @@ -137,944 +104,6 @@ class RenderedPromptResponse(BaseModel): ) -async def _resolve_reference_file_ids_and_names_from_linked_items( - db: AsyncSession, - *, - items: list[ShotLinkedAssetItem], -) -> tuple[list[str], list[str]]: - """将关联资产条目解析为参考图 file_id 列表(顺序有效)。 - - 规则: - - 直接使用 item.file_id(不再通过 type+image_id 查库解析) - - file_id 为空或无效则跳过 - """ - - file_ids: list[str] = [] - names: list[str] = [] - for it in items or []: - name = (it.name or "").strip() - file_id = (it.file_id or "").strip() - if not file_id: - continue - file_ids.append(str(file_id)) - names.append(name or (it.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") - data_url = f"data:image/{image_format};base64,{encoded}" - out.append({"image_url": data_url}) - return out - - -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="Actor not found") - if image_id is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="image_id is required for 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="image_id does not belong to given 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="image_id is required for 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="Prop not found") - 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="image_id does not belong to given 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="Scene not found") - 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="image_id does not belong to given 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="Costume not found") - 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="image_id does not belong to given costume_id", - ) - return image_id, "costume_image" - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="asset_type must be one of: 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="Character not found") - if image_id is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="image_id is required for 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="image_id does not belong to given character_id", - ) - return image_row - - -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 - - -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="Actor not found") - if image_id is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="image_id is required for 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="image_id does not belong to given actor_id", - ) - is_front_view = _is_front_view(image_row.view_angle) - category = _asset_prompt_category( - relation_type="actor_image", - is_front_view=is_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 is_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="image_id is required for 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="Prop not found") - 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="image_id does not belong to given prop_id", - ) - relation_type = "prop_image" - is_front_view = _is_front_view(image_row.view_angle) - category = _asset_prompt_category( - relation_type=relation_type, - is_front_view=is_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 is_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="Scene not found") - 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="image_id does not belong to given scene_id", - ) - relation_type = "scene_image" - is_front_view = _is_front_view(image_row.view_angle) - category = _asset_prompt_category( - relation_type=relation_type, - is_front_view=is_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 is_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="Costume not found") - 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="image_id does not belong to given costume_id", - ) - relation_type = "costume_image" - is_front_view = _is_front_view(image_row.view_angle) - category = _asset_prompt_category( - relation_type=relation_type, - is_front_view=is_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 is_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="asset_type must be one of: 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="Character not found") - if image_id is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="image_id is required for 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="image_id does not belong to given 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, - ) - ref_images = [*actor_refs, *costume_refs] - return prompt, ref_images, 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="ShotDetail not found") - - project = getattr(getattr(getattr(shot_detail, "shot", None), "chapter", None), "project", None) - _vs = getattr(project, "visual_style", None) - visual_style = _vs.value if _vs is not None and hasattr(_vs, "value") else str(_vs or "") - _st = getattr(project, "style", None) - style = _st.value if _st is not None and hasattr(_st, "value") else str(_st 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 - - - - -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: - """创建图片生成任务,并在 `GenerationTaskLink` 中建立关联;Provider 由解析出的 Model 反查。""" - 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, - # 生成参数与参考图统一从 DB 侧控制;此接口不接收覆盖参数。 - "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, - ) - - link = GenerationTaskLink( - task_id=task_record.id, - resource_type="image", - relation_type=relation_type, - relation_entity_id=relation_entity_id, - ) - db.add(link) - await db.commit() - - async def _persist_images_to_assets( - session: AsyncSession, - *, - task_id: str, - relation_type: str, - relation_entity_id: str, - result: ImageGenerationResult, - ) -> None: - """根据 relation_type 将生成的图片落库为 FileItem + 具体资产图片表。 - - - 先将图片内容上传到 S3,创建 FileItem 记录; - - 再根据 relation_type 写入 ActorImage / SceneImage 等业务图片表; - - 当前实现先支持 actor_image / scene_image / prop_image / costume_image,其他类型可按需扩展。 - """ - from sqlalchemy import select - - from app.models.studio import ( - ActorImage, - CharacterImage, - CostumeImage, - PropImage, - SceneImage, - ShotFrameImage, - ) - - # 目前 ImageGenerationResult.images 优先使用 url;若仅有 b64,可在此扩展为下载/解码再上传。 - images = result.images or [] - if not images: - return - - # 简化起见:仅处理第一张图片 - item = images[0] - if not item.url: - # 暂不支持纯 base64 输出的自动落库 - return - - # 使用通用工具方法:从 URL 创建 FileItem 并上传到对象存储 - 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_result = await session.execute(link_stmt) - link_row = link_result.scalars().first() - if link_row is not None: - link_row.file_id = file_id - - # 根据 relation_type 将生成文件填充到已有 image 槽位的 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": - # 角色生成(任务版):落库为 CharacterImage(优先填充 front+low 槽位;没有则创建) - 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_res = await session.execute(stmt_ci) - ci = ci_res.scalars().first() - if ci is not None: - ci.file_id = file_id - ci.format = getattr(ci, "format", "") or "png" - else: - # 创建一个 front+low 的槽位,并设为主图(同一角色只允许一张主图,下面会清理其他主图) - 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: - # 无条件覆盖:如果该 ShotFrameImage 是占位记录,也需要在生成完成后写入新图片。 - 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 _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) - - 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 store2.set_result(task_id, result.model_dump()) - # 任务成功后,自动将生成图片上传到 S3 并落库到资产图片表 - await _persist_images_to_assets( - session, - task_id=task_id, - relation_type=relation_type, - relation_entity_id=relation_entity_id, - result=result, - ) - 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 TaskCreated(task_id=task_record.id) - @router.post( "/actors/{actor_id}/image-tasks", @@ -1094,9 +123,9 @@ async def create_actor_image_generation_task( status_code=status.HTTP_400_BAD_REQUEST, detail="prompt is required for actor generation", ) - image_row = await _validate_actor_image(db, actor_id=actor_id, image_id=body.image_id) - ref_images = await _resolve_reference_image_refs_by_file_ids(db, file_ids=body.images) - created = await _create_image_task_and_link( + image_row = await _validate_actor_image_service(db, actor_id=actor_id, image_id=body.image_id) + ref_images = await _resolve_reference_image_refs_by_file_ids_service(db, file_ids=body.images) + created = await _create_image_task_and_link_service( db=db, model_id=body.model_id, relation_type="actor_image", @@ -1104,7 +133,7 @@ async def create_actor_image_generation_task( prompt=prompt, images=ref_images if ref_images else None, ) - return success_response(created, code=201) + return created_response(created) @router.post( @@ -1118,7 +147,7 @@ async def render_actor_image_prompt( body: StudioImageTaskRequest, db: AsyncSession = Depends(get_db), ) -> ApiResponse[RenderedPromptResponse]: - prompt, images, _image_row = await _build_actor_prompt_and_refs( + prompt, images, _image_row = await _build_actor_prompt_and_refs_service( db, actor_id=actor_id, image_id=body.image_id, @@ -1150,15 +179,15 @@ async def create_asset_image_generation_task( status_code=status.HTTP_400_BAD_REQUEST, detail="prompt is required for asset image generation", ) - image_id, relation_type = await _validate_asset_image_and_relation_type( + image_id, relation_type = await _validate_asset_image_and_relation_type_service( db, asset_type=asset_type, asset_id=asset_id, image_id=body.image_id, ) - ref_images = await _resolve_reference_image_refs_by_file_ids(db, file_ids=body.images) + ref_images = await _resolve_reference_image_refs_by_file_ids_service(db, file_ids=body.images) - created = await _create_image_task_and_link( + created = await _create_image_task_and_link_service( db=db, model_id=body.model_id, relation_type=relation_type, @@ -1166,7 +195,7 @@ async def create_asset_image_generation_task( prompt=prompt, images=ref_images if ref_images else None, ) - return success_response(created, code=201) + return created_response(created) @router.post( @@ -1181,7 +210,7 @@ async def render_asset_image_prompt( body: StudioImageTaskRequest, db: AsyncSession = Depends(get_db), ) -> ApiResponse[RenderedPromptResponse]: - prompt, images, _relation_type = await _build_asset_prompt_and_refs( + prompt, images, _relation_type = await _build_asset_prompt_and_refs_service( db, asset_type=asset_type, asset_id=asset_id, @@ -1212,9 +241,9 @@ async def create_character_image_generation_task( status_code=status.HTTP_400_BAD_REQUEST, detail="prompt is required for character image generation", ) - image_row = await _validate_character_image(db, character_id=character_id, image_id=body.image_id) - ref_images = await _resolve_reference_image_refs_by_file_ids(db, file_ids=body.images) - created = await _create_image_task_and_link( + image_row = await _validate_character_image_service(db, character_id=character_id, image_id=body.image_id) + ref_images = await _resolve_reference_image_refs_by_file_ids_service(db, file_ids=body.images) + created = await _create_image_task_and_link_service( db=db, model_id=body.model_id, relation_type="character_image", @@ -1222,7 +251,7 @@ async def create_character_image_generation_task( prompt=prompt, images=ref_images if ref_images else None, ) - return success_response(created, code=201) + return created_response(created) @router.post( @@ -1236,7 +265,7 @@ async def render_character_image_prompt( body: StudioImageTaskRequest, db: AsyncSession = Depends(get_db), ) -> ApiResponse[RenderedPromptResponse]: - prompt, images, _image_row = await _build_character_prompt_and_refs( + prompt, images, _image_row = await _build_character_prompt_and_refs_service( db, character_id=character_id, image_id=body.image_id, @@ -1265,8 +294,8 @@ async def create_shot_frame_image_generation_task( shot_detail = await db.get(ShotDetail, shot_id) if shot_detail is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ShotDetail not found") - file_ids, _names = await _resolve_reference_file_ids_and_names_from_linked_items(db, items=body.images) - ref_images = await _resolve_reference_image_refs_by_file_ids(db, file_ids=file_ids) + file_ids, _names = await _resolve_reference_file_ids_and_names_from_linked_items_service(db, items=body.images) + ref_images = await _resolve_reference_image_refs_by_file_ids_service(db, file_ids=file_ids) # 通过 shot_id 与 frame_type 定位 ShotFrameImage,作为落库目标;若不存在则创建占位记录。 shot_frame_image_stmt = ( @@ -1294,7 +323,7 @@ async def create_shot_frame_image_generation_task( if not shot_frame_image.format: shot_frame_image.format = "png" - created = await _create_image_task_and_link( + created = await _create_image_task_and_link_service( db=db, model_id=body.model_id, relation_type="shot_frame_image", @@ -1302,7 +331,7 @@ async def create_shot_frame_image_generation_task( prompt=prompt, images=ref_images if ref_images else None, ) - return success_response(created, code=201) + return created_response(created) @router.post( @@ -1316,19 +345,21 @@ async def render_shot_frame_prompt( body: ShotFrameImageTaskRequest, db: AsyncSession = Depends(get_db), ) -> ApiResponse[RenderedPromptResponse]: - # 不再从 ShotDetail 自动构建提示词:直接使用请求体中的 prompt prompt = (body.prompt or "").strip() - file_ids, names = await _resolve_reference_file_ids_and_names_from_linked_items(db, items=body.images) - # 若 prompt 为空,则不进行任何拼接,直接返回空 prompt - if not prompt: - return success_response(RenderedPromptResponse(prompt="", images=file_ids)) - - if names: - lines = ["## 图片内容说明"] - for i, n in enumerate(names, start=1): - lines.append(f"图{i}: {n}") - lines.append("## 生成内容") - header = "\n".join(lines).strip() + "\n" - prompt = header + prompt - return success_response(RenderedPromptResponse(prompt=prompt, images=file_ids)) + file_ids, names = await _resolve_reference_file_ids_and_names_from_linked_items_service(db, items=body.images) + if prompt: + if names: + lines = ["## 图片内容说明"] + for i, n in enumerate(names, start=1): + lines.append(f"图{i}: {n}") + lines.append("## 生成内容") + header = "\n".join(lines).strip() + "\n" + prompt = header + prompt + return success_response(RenderedPromptResponse(prompt=prompt, images=file_ids)) + rendered_prompt, rendered_images, _detail = await _build_shot_frame_prompt_and_refs_service( + db, + shot_id=shot_id, + frame_type=body.frame_type, + ) + return success_response(RenderedPromptResponse(prompt=rendered_prompt, images=rendered_images)) diff --git a/backend/app/api/v1/routes/studio/projects.py b/backend/app/api/v1/routes/studio/projects.py index 3cb324f..e3804c6 100644 --- a/backend/app/api/v1/routes/studio/projects.py +++ b/backend/app/api/v1/routes/studio/projects.py @@ -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() diff --git a/backend/app/api/v1/routes/studio/prompts.py b/backend/app/api/v1/routes/studio/prompts.py index 178ac21..f7ce85b 100644 --- a/backend/app/api/v1/routes/studio/prompts.py +++ b/backend/app/api/v1/routes/studio/prompts.py @@ -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() diff --git a/backend/app/api/v1/routes/studio/shot_character_links.py b/backend/app/api/v1/routes/studio/shot_character_links.py index cb6428e..0fc2e56 100644 --- a/backend/app/api/v1/routes/studio/shot_character_links.py +++ b/backend/app/api/v1/routes/studio/shot_character_links.py @@ -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)) - diff --git a/backend/app/api/v1/routes/studio/shots.py b/backend/app/api/v1/routes/studio/shots.py index 8365ba3..b84b6a2 100644 --- a/backend/app/api/v1/routes/studio/shots.py +++ b/backend/app/api/v1/routes/studio/shots.py @@ -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() diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py index fc456fb..1b3bc2e 100644 --- a/backend/app/schemas/common.py +++ b/backend/app/schemas/common.py @@ -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], *, diff --git a/backend/app/schemas/llm.py b/backend/app/schemas/llm.py index 0c7019c..158f736 100644 --- a/backend/app/schemas/llm.py +++ b/backend/app/schemas/llm.py @@ -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 - diff --git a/backend/app/schemas/studio/assets.py b/backend/app/schemas/studio/assets.py index e09670e..6572a4e 100644 --- a/backend/app/schemas/studio/assets.py +++ b/backend/app/schemas/studio/assets.py @@ -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 - diff --git a/backend/app/schemas/studio/cast.py b/backend/app/schemas/studio/cast.py index 3016138..b3b3544 100644 --- a/backend/app/schemas/studio/cast.py +++ b/backend/app/schemas/studio/cast.py @@ -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) diff --git a/backend/app/schemas/studio/cast_images.py b/backend/app/schemas/studio/cast_images.py index 1f10bda..0006065 100644 --- a/backend/app/schemas/studio/cast_images.py +++ b/backend/app/schemas/studio/cast_images.py @@ -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 - diff --git a/backend/app/schemas/studio/files.py b/backend/app/schemas/studio/files.py index 63c0067..052a1c6 100644 --- a/backend/app/schemas/studio/files.py +++ b/backend/app/schemas/studio/files.py @@ -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) diff --git a/backend/app/schemas/studio/projects.py b/backend/app/schemas/studio/projects.py index 7ddb958..8729cc1 100644 --- a/backend/app/schemas/studio/projects.py +++ b/backend/app/schemas/studio/projects.py @@ -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 - diff --git a/backend/app/schemas/studio/prompts.py b/backend/app/schemas/studio/prompts.py index ee4e9d6..d5741c0 100644 --- a/backend/app/schemas/studio/prompts.py +++ b/backend/app/schemas/studio/prompts.py @@ -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 - diff --git a/backend/app/schemas/studio/shots.py b/backend/app/schemas/studio/shots.py index 9867a3d..9e39dd2 100644 --- a/backend/app/schemas/studio/shots.py +++ b/backend/app/schemas/studio/shots.py @@ -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)") - diff --git a/backend/app/services/common/__init__.py b/backend/app/services/common/__init__.py new file mode 100644 index 0000000..8bf9ee7 --- /dev/null +++ b/backend/app/services/common/__init__.py @@ -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", +] diff --git a/backend/app/services/common/crud.py b/backend/app/services/common/crud.py new file mode 100644 index 0000000..af2737c --- /dev/null +++ b/backend/app/services/common/crud.py @@ -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() diff --git a/backend/app/services/common/errors.py b/backend/app/services/common/errors.py new file mode 100644 index 0000000..61b0248 --- /dev/null +++ b/backend/app/services/common/errors.py @@ -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}" diff --git a/backend/app/services/common/validators.py b/backend/app/services/common/validators.py new file mode 100644 index 0000000..e42c3a2 --- /dev/null +++ b/backend/app/services/common/validators.py @@ -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) diff --git a/backend/app/services/film/__init__.py b/backend/app/services/film/__init__.py new file mode 100644 index 0000000..f5e11bb --- /dev/null +++ b/backend/app/services/film/__init__.py @@ -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", +] diff --git a/backend/app/services/film/generated_video.py b/backend/app/services/film/generated_video.py new file mode 100644 index 0000000..025e4f7 --- /dev/null +++ b/backend/app/services/film/generated_video.py @@ -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() diff --git a/backend/app/services/film/shot_frame_prompt_tasks.py b/backend/app/services/film/shot_frame_prompt_tasks.py new file mode 100644 index 0000000..1c18d3b --- /dev/null +++ b/backend/app/services/film/shot_frame_prompt_tasks.py @@ -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() diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py index b91d3fe..5ba8fe4 100644 --- a/backend/app/services/llm/__init__.py +++ b/backend/app/services/llm/__init__.py @@ -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", ] - diff --git a/backend/app/services/llm/manage.py b/backend/app/services/llm/manage.py new file mode 100644 index 0000000..ae842fe --- /dev/null +++ b/backend/app/services/llm/manage.py @@ -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) diff --git a/backend/app/services/llm/resolver.py b/backend/app/services/llm/resolver.py index 091ec23..ed6b1e8 100644 --- a/backend/app/services/llm/resolver.py +++ b/backend/app/services/llm/resolver.py @@ -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) - diff --git a/backend/app/services/studio/__init__.py b/backend/app/services/studio/__init__.py index 92e1dae..0b6a1a0 100644 --- a/backend/app/services/studio/__init__.py +++ b/backend/app/services/studio/__init__.py @@ -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);在某些轻量环境中可能未安装。 diff --git a/backend/app/services/studio/entities.py b/backend/app/services/studio/entities.py index cb0569c..acb7f35 100644 --- a/backend/app/services/studio/entities.py +++ b/backend/app/services/studio/entities.py @@ -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, + ) diff --git a/backend/app/services/studio/entity_crud.py b/backend/app/services/studio/entity_crud.py new file mode 100644 index 0000000..9be4957 --- /dev/null +++ b/backend/app/services/studio/entity_crud.py @@ -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() diff --git a/backend/app/services/studio/entity_existence.py b/backend/app/services/studio/entity_existence.py new file mode 100644 index 0000000..1d4f708 --- /dev/null +++ b/backend/app/services/studio/entity_existence.py @@ -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, + } diff --git a/backend/app/services/studio/entity_images.py b/backend/app/services/studio/entity_images.py new file mode 100644 index 0000000..febf8c4 --- /dev/null +++ b/backend/app/services/studio/entity_images.py @@ -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() diff --git a/backend/app/services/studio/entity_specs.py b/backend/app/services/studio/entity_specs.py new file mode 100644 index 0000000..4f91076 --- /dev/null +++ b/backend/app/services/studio/entity_specs.py @@ -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, + ) diff --git a/backend/app/services/studio/entity_thumbnails.py b/backend/app/services/studio/entity_thumbnails.py new file mode 100644 index 0000000..ebbe17e --- /dev/null +++ b/backend/app/services/studio/entity_thumbnails.py @@ -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() + } diff --git a/backend/app/services/studio/files.py b/backend/app/services/studio/files.py new file mode 100644 index 0000000..5b3f70b --- /dev/null +++ b/backend/app/services/studio/files.py @@ -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() diff --git a/backend/app/services/studio/image_task_prompts.py b/backend/app/services/studio/image_task_prompts.py new file mode 100644 index 0000000..a1e9c73 --- /dev/null +++ b/backend/app/services/studio/image_task_prompts.py @@ -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 diff --git a/backend/app/services/studio/image_task_references.py b/backend/app/services/studio/image_task_references.py new file mode 100644 index 0000000..1acbde3 --- /dev/null +++ b/backend/app/services/studio/image_task_references.py @@ -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 diff --git a/backend/app/services/studio/image_task_runner.py b/backend/app/services/studio/image_task_runner.py new file mode 100644 index 0000000..7039bae --- /dev/null +++ b/backend/app/services/studio/image_task_runner.py @@ -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) diff --git a/backend/app/services/studio/image_task_validation.py b/backend/app/services/studio/image_task_validation.py new file mode 100644 index 0000000..bf3a5ef --- /dev/null +++ b/backend/app/services/studio/image_task_validation.py @@ -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 diff --git a/backend/app/services/studio/image_tasks.py b/backend/app/services/studio/image_tasks.py index 899efc0..00d0397 100644 --- a/backend/app/services/studio/image_tasks.py +++ b/backend/app/services/studio/image_tasks.py @@ -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 - diff --git a/backend/app/services/studio/script_division.py b/backend/app/services/studio/script_division.py new file mode 100644 index 0000000..3251eda --- /dev/null +++ b/backend/app/services/studio/script_division.py @@ -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() diff --git a/backend/app/services/studio/shot_assets.py b/backend/app/services/studio/shot_assets.py new file mode 100644 index 0000000..1d3c1ff --- /dev/null +++ b/backend/app/services/studio/shot_assets.py @@ -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) diff --git a/backend/app/services/studio/shot_character_links.py b/backend/app/services/studio/shot_character_links.py new file mode 100644 index 0000000..a34efe6 --- /dev/null +++ b/backend/app/services/studio/shot_character_links.py @@ -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, + ), + ) diff --git a/backend/app/services/studio/shot_details.py b/backend/app/services/studio/shot_details.py new file mode 100644 index 0000000..1a8275b --- /dev/null +++ b/backend/app/services/studio/shot_details.py @@ -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) diff --git a/backend/app/services/studio/shot_dialogs.py b/backend/app/services/studio/shot_dialogs.py new file mode 100644 index 0000000..8056100 --- /dev/null +++ b/backend/app/services/studio/shot_dialogs.py @@ -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) diff --git a/backend/app/services/studio/shot_extraction_draft.py b/backend/app/services/studio/shot_extraction_draft.py index d5b401d..7b99ae3 100644 --- a/backend/app/services/studio/shot_extraction_draft.py +++ b/backend/app/services/studio/shot_extraction_draft.py @@ -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() diff --git a/backend/app/services/studio/shot_frames.py b/backend/app/services/studio/shot_frames.py new file mode 100644 index 0000000..0dcd69e --- /dev/null +++ b/backend/app/services/studio/shot_frames.py @@ -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) diff --git a/backend/app/services/studio/shots.py b/backend/app/services/studio/shots.py new file mode 100644 index 0000000..730c6c7 --- /dev/null +++ b/backend/app/services/studio/shots.py @@ -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) diff --git a/backend/app/utils/project_links.py b/backend/app/utils/project_links.py index 9662209..6a88049 100644 --- a/backend/app/utils/project_links.py +++ b/backend/app/utils/project_links.py @@ -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 - diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7f240f3..a93efcd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0ebcf01..2ab95d2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_api_response_envelopes.py b/backend/tests/test_api_response_envelopes.py new file mode 100644 index 0000000..e1510ab --- /dev/null +++ b/backend/tests/test_api_response_envelopes.py @@ -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"] diff --git a/backend/tests/test_common_services.py b/backend/tests/test_common_services.py new file mode 100644 index 0000000..5ce19c3 --- /dev/null +++ b/backend/tests/test_common_services.py @@ -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() diff --git a/backend/tests/test_entities_api_responses.py b/backend/tests/test_entities_api_responses.py new file mode 100644 index 0000000..4d73b14 --- /dev/null +++ b/backend/tests/test_entities_api_responses.py @@ -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, + } diff --git a/backend/tests/test_entity_existence_api_responses.py b/backend/tests/test_entity_existence_api_responses.py new file mode 100644 index 0000000..57875e6 --- /dev/null +++ b/backend/tests/test_entity_existence_api_responses.py @@ -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, + } diff --git a/backend/tests/test_files_api_responses.py b/backend/tests/test_files_api_responses.py new file mode 100644 index 0000000..62f5723 --- /dev/null +++ b/backend/tests/test_files_api_responses.py @@ -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"] == "封面图" diff --git a/backend/tests/test_generated_video_api_responses.py b/backend/tests/test_generated_video_api_responses.py new file mode 100644 index 0000000..5a6b9b1 --- /dev/null +++ b/backend/tests/test_generated_video_api_responses.py @@ -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"] diff --git a/backend/tests/test_generated_video_service.py b/backend/tests/test_generated_video_service.py new file mode 100644 index 0000000..163261f --- /dev/null +++ b/backend/tests/test_generated_video_service.py @@ -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() diff --git a/backend/tests/test_image_task_services.py b/backend/tests/test_image_task_services.py new file mode 100644 index 0000000..6b3af26 --- /dev/null +++ b/backend/tests/test_image_task_services.py @@ -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"] == "图一逼近图二" diff --git a/backend/tests/test_image_tasks_api_responses.py b/backend/tests/test_image_tasks_api_responses.py new file mode 100644 index 0000000..3382cd9 --- /dev/null +++ b/backend/tests/test_image_tasks_api_responses.py @@ -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"] diff --git a/backend/tests/test_llm_api_responses.py b/backend/tests/test_llm_api_responses.py new file mode 100644 index 0000000..ce17d7e --- /dev/null +++ b/backend/tests/test_llm_api_responses.py @@ -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"] diff --git a/backend/tests/test_llm_manage.py b/backend/tests/test_llm_manage.py new file mode 100644 index 0000000..72fe9f5 --- /dev/null +++ b/backend/tests/test_llm_manage.py @@ -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() diff --git a/backend/tests/test_script_division.py b/backend/tests/test_script_division.py new file mode 100644 index 0000000..fc4814f --- /dev/null +++ b/backend/tests/test_script_division.py @@ -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() diff --git a/backend/tests/test_shot_character_links.py b/backend/tests/test_shot_character_links.py new file mode 100644 index 0000000..dca725d --- /dev/null +++ b/backend/tests/test_shot_character_links.py @@ -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() diff --git a/backend/tests/test_shot_character_links_api_responses.py b/backend/tests/test_shot_character_links_api_responses.py new file mode 100644 index 0000000..82820b0 --- /dev/null +++ b/backend/tests/test_shot_character_links_api_responses.py @@ -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, + } diff --git a/backend/tests/test_shot_frame_prompt_tasks_service.py b/backend/tests/test_shot_frame_prompt_tasks_service.py new file mode 100644 index 0000000..02e8431 --- /dev/null +++ b/backend/tests/test_shot_frame_prompt_tasks_service.py @@ -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() diff --git a/backend/tests/test_shot_subresource_api_responses.py b/backend/tests/test_shot_subresource_api_responses.py new file mode 100644 index 0000000..5e46236 --- /dev/null +++ b/backend/tests/test_shot_subresource_api_responses.py @@ -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"] diff --git a/backend/tests/test_studio_api_responses.py b/backend/tests/test_studio_api_responses.py new file mode 100644 index 0000000..393f4c9 --- /dev/null +++ b/backend/tests/test_studio_api_responses.py @@ -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"] diff --git a/backend/tests/test_studio_files_service.py b/backend/tests/test_studio_files_service.py new file mode 100644 index 0000000..716a6b1 --- /dev/null +++ b/backend/tests/test_studio_files_service.py @@ -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() diff --git a/backend/tests/test_task_status_api_responses.py b/backend/tests/test_task_status_api_responses.py new file mode 100644 index 0000000..c9eaf54 --- /dev/null +++ b/backend/tests/test_task_status_api_responses.py @@ -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 diff --git a/backend/tests/test_tasks_images_api_responses.py b/backend/tests/test_tasks_images_api_responses.py new file mode 100644 index 0000000..e30ae57 --- /dev/null +++ b/backend/tests/test_tasks_images_api_responses.py @@ -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"] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..1909a1c --- /dev/null +++ b/conftest.py @@ -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 diff --git a/front/pnpm-lock.yaml b/front/pnpm-lock.yaml index 470bbed..6399c26 100644 --- a/front/pnpm-lock.yaml +++ b/front/pnpm-lock.yaml @@ -1,198 +1,2637 @@ -lockfileVersion: '6.0' +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false -dependencies: - '@ant-design/icons': - specifier: ^5.2.6 - version: 5.2.6(react-dom@18.2.0)(react@18.2.0) - antd: - specifier: ^5.10.0 - version: 5.10.0(react-dom@18.2.0)(react@18.2.0) - axios: - specifier: ^1.13.6 - version: 1.13.6 - i18next: - specifier: ^23.11.4 - version: 23.11.4 - i18next-browser-languagedetector: - specifier: ^7.2.1 - version: 7.2.1 - react: - specifier: ^18.2.0 - version: 18.2.0 - react-beautiful-dnd: - specifier: ^13.1.1 - version: 13.1.1(react-dom@18.2.0)(react@18.2.0) - react-dom: - specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) - react-i18next: - specifier: ^15.0.1 - version: 15.0.1(i18next@23.11.4)(react-dom@18.2.0)(react@18.2.0) - react-router-dom: - specifier: ^6.30.3 - version: 6.30.3(react-dom@18.2.0)(react@18.2.0) - zustand: - specifier: ^5.0.11 - version: 5.0.11(@types/react@18.2.37)(react@18.2.0) +importers: -devDependencies: - '@types/react': - specifier: ^18.2.37 - version: 18.2.37 - '@types/react-beautiful-dnd': - specifier: ^13.1.8 - version: 13.1.8 - '@types/react-dom': - specifier: ^18.2.15 - version: 18.2.15 - '@typescript-eslint/eslint-plugin': - specifier: ^6.13.2 - version: 6.13.2(@typescript-eslint/parser@6.13.2)(eslint@8.54.0)(typescript@5.2.2) - '@typescript-eslint/parser': - specifier: ^6.13.2 - version: 6.13.2(eslint@8.54.0)(typescript@5.2.2) - '@vitejs/plugin-react': - specifier: ^4.2.1 - version: 4.2.1(vite@5.0.8) - autoprefixer: - specifier: ^10.4.16 - version: 10.4.16(postcss@8.4.32) - eslint: - specifier: ^8.54.0 - version: 8.54.0 - eslint-plugin-react: - specifier: ^7.33.2 - version: 7.33.2(eslint@8.54.0) - eslint-plugin-react-hooks: - specifier: ^4.6.0 - version: 4.6.0(eslint@8.54.0) - msw: - specifier: ^2.6.6 - version: 2.6.6(typescript@5.2.2) - openapi-typescript-codegen: - specifier: ^0.30.0 - version: 0.30.0(@types/json-schema@7.0.15) - postcss: - specifier: ^8.4.32 - version: 8.4.32 - tailwindcss: - specifier: ^3.3.6 - version: 3.3.6 - typescript: - specifier: ^5.2.2 - version: 5.2.2 - vite: - specifier: ^5.0.8 - version: 5.0.8 + .: + dependencies: + '@ant-design/icons': + specifier: ^5.2.6 + version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + antd: + specifier: ^5.10.0 + version: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + axios: + specifier: ^1.13.6 + version: 1.14.0 + i18next: + specifier: ^23.11.4 + version: 23.16.8 + i18next-browser-languagedetector: + specifier: ^7.2.1 + version: 7.2.2 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-beautiful-dnd: + specifier: ^13.1.1 + version: 13.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^15.0.1 + version: 15.7.4(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + react-router-dom: + specifier: ^6.30.3 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zustand: + specifier: ^5.0.11 + version: 5.0.12(@types/react@18.3.28)(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.2.37 + version: 18.3.28 + '@types/react-beautiful-dnd': + specifier: ^13.1.8 + version: 13.1.8 + '@types/react-dom': + specifier: ^18.2.15 + version: 18.3.7(@types/react@18.3.28) + '@typescript-eslint/eslint-plugin': + specifier: ^6.13.2 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.13.2 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@5.4.21) + autoprefixer: + specifier: ^10.4.16 + version: 10.4.27(postcss@8.5.8) + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-plugin-react: + specifier: ^7.33.2 + version: 7.37.5(eslint@8.57.1) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.2(eslint@8.57.1) + msw: + specifier: ^2.6.6 + version: 2.12.14(typescript@5.9.3) + openapi-typescript-codegen: + specifier: ^0.30.0 + version: 0.30.0(@types/json-schema@7.0.15) + postcss: + specifier: ^8.4.32 + version: 8.5.8 + tailwindcss: + specifier: ^3.3.6 + version: 3.4.19 + typescript: + specifier: ^5.2.2 + version: 5.9.3 + vite: + specifier: ^5.0.8 + version: 5.4.21 packages: - /@alloc/quick-lru@5.2.0: + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - dev: true - /@ant-design/colors@7.2.1: + '@ant-design/colors@7.2.1': resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==} - dependencies: - '@ant-design/fast-color': 2.0.6 - dev: false - /@ant-design/cssinjs@1.24.0(react-dom@18.2.0)(react@18.2.0): + '@ant-design/cssinjs-utils@1.1.3': + resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@ant-design/cssinjs@1.24.0': resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} peerDependencies: react: '>=16.0.0' react-dom: '>=16.0.0' - dependencies: - '@babel/runtime': 7.28.6 - '@emotion/hash': 0.8.0 - '@emotion/unitless': 0.7.5 - classnames: 2.5.1 - csstype: 3.2.3 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - stylis: 4.3.6 - dev: false - /@ant-design/fast-color@2.0.6: + '@ant-design/fast-color@2.0.6': resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==} engines: {node: '>=8.x'} - dependencies: - '@babel/runtime': 7.28.6 - dev: false - /@ant-design/icons-svg@4.4.2: + '@ant-design/icons-svg@4.4.2': resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==} - dev: false - /@ant-design/icons@5.2.6(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-4wn0WShF43TrggskBJPRqCD0fcHbzTYjnaoskdiJrVHg86yxoZ8ZUqsXvyn4WUqehRiFKnaclOhqk9w4Ui2KVw==} + '@ant-design/icons@5.6.1': + resolution: {integrity: sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==} engines: {node: '>=8'} peerDependencies: react: '>=16.0.0' react-dom: '>=16.0.0' - dependencies: - '@ant-design/colors': 7.2.1 - '@ant-design/icons-svg': 4.4.2 - '@babel/runtime': 7.28.6 - classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /@ant-design/react-slick@1.0.2(react@18.2.0): - resolution: {integrity: sha512-Wj8onxL/T8KQLFFiCA4t8eIRGpRR+UPgOdac2sYzonv+i0n3kXHmvHLLiOYL655DQx2Umii9Y9nNgL7ssu5haQ==} + '@ant-design/react-slick@1.1.2': + resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==} peerDependencies: react: '>=16.9.0' - dependencies: - '@babel/runtime': 7.28.6 - classnames: 2.5.1 - json2mq: 0.2.0 - react: 18.2.0 - resize-observer-polyfill: 1.5.1 - throttle-debounce: 5.0.2 - dev: false - /@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15): + '@apidevtools/json-schema-ref-parser@14.2.1': resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} engines: {node: '>= 20'} peerDependencies: '@types/json-schema': ^7.0.15 + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@rc-component/async-validator@5.1.0': + resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} + engines: {node: '>=14.x'} + + '@rc-component/color-picker@2.0.1': + resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@1.4.0': + resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.3': + resolution: {integrity: sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==} + engines: {node: '>=8.x'} + + '@rc-component/mutate-observer@1.1.0': + resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/portal@1.1.2': + resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.1.1': + resolution: {integrity: sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tour@1.15.1': + resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/trigger@2.3.1': + resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hoist-non-react-statics@3.3.7': + resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} + peerDependencies: + '@types/react': '*' + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-beautiful-dnd@13.1.8': + resolution: {integrity: sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react-redux@7.1.34': + resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==} + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + antd@5.29.3: + resolution: {integrity: sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001784: + resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.331: + resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.1: + resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next-browser-languagedetector@7.2.2: + resolution: {integrity: sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ==} + + i18next@23.16.8: + resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.14: + resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openapi-typescript-codegen@0.30.0: + resolution: {integrity: sha512-NO24vrOYEEREkuEwtLemXiV0/3wUj1HvS+0UuAinVNWKJOyNlXTj5hehdW9Dyob4u5YGrRG9dc9TBZW7/UszGw==} + hasBin: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + + rc-cascader@3.34.0: + resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.5.0: + resolution: {integrity: sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.9.0: + resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.6.0: + resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@7.3.0: + resolution: {integrity: sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.2.1: + resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@2.7.1: + resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.12.0: + resolution: {integrity: sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@9.5.0: + resolution: {integrity: sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.8.0: + resolution: {integrity: sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.20.0: + resolution: {integrity: sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.16.1: + resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-notification@5.6.4: + resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-overflow@1.5.0: + resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-pagination@5.1.0: + resolution: {integrity: sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@4.11.3: + resolution: {integrity: sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + rc-progress@4.0.0: + resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.13.1: + resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-resize-observer@1.4.3: + resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.7.1: + resolution: {integrity: sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.16.8: + resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@11.1.9: + resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-steps@6.0.1: + resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-switch@4.1.0: + resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-table@7.54.0: + resolution: {integrity: sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@15.7.0: + resolution: {integrity: sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.10.2: + resolution: {integrity: sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.4.0: + resolution: {integrity: sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.27.0: + resolution: {integrity: sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.13.1: + resolution: {integrity: sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.11.0: + resolution: {integrity: sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-virtual-list@3.19.2: + resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + react-beautiful-dnd@13.1.1: + resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} + deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672' + peerDependencies: + react: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-i18next@15.7.4: + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-redux@7.2.9: + resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} + peerDependencies: + react: ^16.8.3 || ^17 || ^18 + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@5.5.0: + resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + engines: {node: '>=20'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-memo-one@1.1.3: + resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/cssinjs-utils@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.29.2 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/cssinjs@1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + classnames: 2.5.1 + csstype: 3.2.3 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + stylis: 4.3.6 + + '@ant-design/fast-color@2.0.6': + dependencies: + '@babel/runtime': 7.29.2 + + '@ant-design/icons-svg@4.4.2': {} + + '@ant-design/icons@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/icons-svg': 4.4.2 + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/react-slick@1.1.2(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 18.3.1 + resize-observer-polyfill: 1.5.1 + throttle-debounce: 5.0.2 + + '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': dependencies: '@types/json-schema': 7.0.15 js-yaml: 4.1.1 - dev: true - /@babel/code-frame@7.29.0: - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - dev: true - /@babel/compat-data@7.29.0: - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - dev: true + '@babel/compat-data@7.29.0': {} - /@babel/core@7.29.0: - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -204,50 +2643,33 @@ packages: semver: 6.3.1 transitivePeerDependencies: - supports-color - dev: true - /@babel/generator@7.29.1: - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - dev: true - /@babel/helper-compilation-targets@7.28.6: - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - dev: true - /@babel/helper-globals@7.28.0: - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - dev: true + '@babel/helper-globals@7.28.0': {} - /@babel/helper-module-imports@7.28.6: - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - dev: true - /@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0): - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 @@ -255,358 +2677,140 @@ packages: '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - dev: true - /@babel/helper-plugin-utils@7.28.6: - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - dev: true + '@babel/helper-plugin-utils@7.28.6': {} - /@babel/helper-string-parser@7.27.1: - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - dev: true + '@babel/helper-string-parser@7.27.1': {} - /@babel/helper-validator-identifier@7.28.5: - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - dev: true + '@babel/helper-validator-identifier@7.28.5': {} - /@babel/helper-validator-option@7.27.1: - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - dev: true + '@babel/helper-validator-option@7.27.1': {} - /@babel/helpers@7.28.6: - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - dev: true - /@babel/parser@7.29.0: - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true + '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 - dev: true - /@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0): - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - dev: true - /@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0): - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - dev: true - /@babel/runtime@7.28.6: - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} - engines: {node: '>=6.9.0'} - dev: false + '@babel/runtime@7.29.2': {} - /@babel/template@7.28.6: - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 - dev: true - /@babel/traverse@7.29.0: - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: true - /@babel/types@7.29.0: - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - dev: true - /@bundled-es-modules/cookie@2.0.1: - resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: - cookie: 0.7.2 - dev: true - - /@bundled-es-modules/statuses@1.0.1: - resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} - dependencies: - statuses: 2.0.2 - dev: true - - /@bundled-es-modules/tough-cookie@0.1.6: - resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} - dependencies: - '@types/tough-cookie': 4.0.5 - tough-cookie: 4.1.4 - dev: true - - /@ctrl/tinycolor@3.6.1: - resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} - engines: {node: '>=10'} - dev: false - - /@emotion/hash@0.8.0: - resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} - dev: false - - /@emotion/unitless@0.7.5: - resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} - dev: false - - /@esbuild/aix-ppc64@0.19.12: - resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm64@0.19.12: - resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm@0.19.12: - resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-x64@0.19.12: - resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-arm64@0.19.12: - resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-x64@0.19.12: - resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-arm64@0.19.12: - resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-x64@0.19.12: - resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm64@0.19.12: - resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm@0.19.12: - resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ia32@0.19.12: - resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-loong64@0.19.12: - resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-mips64el@0.19.12: - resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ppc64@0.19.12: - resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-riscv64@0.19.12: - resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-s390x@0.19.12: - resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-x64@0.19.12: - resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/netbsd-x64@0.19.12: - resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/openbsd-x64@0.19.12: - resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/sunos-x64@0.19.12: - resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-arm64@0.19.12: - resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-ia32@0.19.12: - resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-x64@0.19.12: - resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@eslint-community/eslint-utils@4.9.1(eslint@8.54.0): - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 8.54.0 + eslint: 8.57.1 eslint-visitor-keys: 3.4.3 - dev: true - /@eslint-community/regexpp@4.12.2: - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - dev: true + '@eslint-community/regexpp@4.12.2': {} - /@eslint/eslintrc@2.1.4: - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.14.0 debug: 4.4.3 @@ -619,61 +2823,29 @@ packages: strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - dev: true - /@eslint/js@8.54.0: - resolution: {integrity: sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true + '@eslint/js@8.57.1': {} - /@humanwhocodes/config-array@0.11.14: - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color - dev: true - /@humanwhocodes/module-importer@1.0.1: - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - dev: true + '@humanwhocodes/module-importer@1.0.1': {} - /@humanwhocodes/object-schema@2.0.3: - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead - dev: true + '@humanwhocodes/object-schema@2.0.3': {} - /@inquirer/ansi@1.0.2: - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} - dev: true + '@inquirer/ansi@1.0.2': {} - /@inquirer/confirm@5.1.21: - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true + '@inquirer/confirm@5.1.21': dependencies: '@inquirer/core': 10.3.2 '@inquirer/type': 3.0.10 - dev: true - /@inquirer/core@10.3.2: - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true + '@inquirer/core@10.3.2': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 @@ -683,56 +2855,31 @@ packages: signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 - dev: true - /@inquirer/figures@1.0.15: - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - dev: true + '@inquirer/figures@1.0.15': {} - /@inquirer/type@3.0.10: - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - dev: true + '@inquirer/type@3.0.10': {} - /@jridgewell/gen-mapping@0.3.13: - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - dev: true - /@jridgewell/remapping@2.3.5: - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - dev: true - /@jridgewell/resolve-uri@3.1.2: - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - dev: true + '@jridgewell/resolve-uri@3.1.2': {} - /@jridgewell/sourcemap-codec@1.5.5: - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - dev: true + '@jridgewell/sourcemap-codec@1.5.5': {} - /@jridgewell/trace-mapping@0.3.31: - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - dev: true - /@mswjs/interceptors@0.37.6: - resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} - engines: {node: '>=18'} + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -740,731 +2887,424 @@ packages: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 - dev: true - /@nodelib/fs.scandir@2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true - /@nodelib/fs.stat@2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - dev: true + '@nodelib/fs.stat@2.0.5': {} - /@nodelib/fs.walk@1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - dev: true - /@open-draft/deferred-promise@2.2.0: - resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - dev: true + '@open-draft/deferred-promise@2.2.0': {} - /@open-draft/logger@0.3.0: - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 - dev: true - /@open-draft/until@2.1.0: - resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - dev: true + '@open-draft/until@2.1.0': {} - /@rc-component/color-picker@1.4.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-vh5EWqnsayZa/JwUznqDaPJz39jznx/YDbyBuVJntv735tKXKwEUZZb2jYEldOg+NKWZwtALjGMrNeGBmqFoEw==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + '@rc-component/async-validator@5.1.0': dependencies: - '@babel/runtime': 7.28.6 - '@ctrl/tinycolor': 3.6.1 + '@babel/runtime': 7.29.2 + + '@rc-component/color-picker@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/fast-color': 2.0.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /@rc-component/context@1.4.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + '@rc-component/context@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.28.6 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + '@babel/runtime': 7.29.2 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /@rc-component/mini-decimal@1.1.0: - resolution: {integrity: sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==} - engines: {node: '>=8.x'} + '@rc-component/mini-decimal@1.1.3': dependencies: - '@babel/runtime': 7.28.6 - dev: false + '@babel/runtime': 7.29.2 - /@rc-component/mutate-observer@1.1.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + '@rc-component/mutate-observer@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /@rc-component/portal@1.1.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + '@rc-component/portal@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /@rc-component/tour@1.10.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-voV0BKaTJbewB9LLgAHQ7tAGG7rgDkKQkZo82xw2gIk542hY+o7zwoqdN16oHhIKk7eG/xi+mdXrONT62Dt57A==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + '@rc-component/qrcode@1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tour@1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /@rc-component/trigger@1.18.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Ksr25pXreYe1gX6ayZ1jLrOrl9OAUHUqnuhEx6MeHnNa1zVM5Y2Aj3Q35UrER0ns8D2cJYtmJtVli+i+4eKrvA==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + '@rc-component/trigger@2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /@remix-run/router@1.23.2: - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - dev: false + '@remix-run/router@1.23.2': {} - /@rollup/rollup-android-arm-eabi@4.59.0: - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true - /@rollup/rollup-android-arm64@4.59.0: - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true + '@rollup/rollup-android-arm64@4.60.1': optional: true - /@rollup/rollup-darwin-arm64@4.59.0: - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true + '@rollup/rollup-darwin-arm64@4.60.1': optional: true - /@rollup/rollup-darwin-x64@4.59.0: - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true + '@rollup/rollup-darwin-x64@4.60.1': optional: true - /@rollup/rollup-freebsd-arm64@4.59.0: - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true + '@rollup/rollup-freebsd-arm64@4.60.1': optional: true - /@rollup/rollup-freebsd-x64@4.59.0: - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true + '@rollup/rollup-freebsd-x64@4.60.1': optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.59.0: - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} - cpu: [arm] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': optional: true - /@rollup/rollup-linux-arm-musleabihf@4.59.0: - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm-musleabihf@4.60.1': optional: true - /@rollup/rollup-linux-arm64-gnu@4.59.0: - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm64-gnu@4.60.1': optional: true - /@rollup/rollup-linux-arm64-musl@4.59.0: - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} - cpu: [arm64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true - /@rollup/rollup-linux-loong64-gnu@4.59.0: - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - /@rollup/rollup-linux-loong64-musl@4.59.0: - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true - /@rollup/rollup-linux-ppc64-gnu@4.59.0: - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - /@rollup/rollup-linux-ppc64-musl@4.59.0: - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-ppc64-musl@4.60.1': optional: true - /@rollup/rollup-linux-riscv64-gnu@4.59.0: - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-riscv64-gnu@4.60.1': optional: true - /@rollup/rollup-linux-riscv64-musl@4.59.0: - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-riscv64-musl@4.60.1': optional: true - /@rollup/rollup-linux-s390x-gnu@4.59.0: - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} - cpu: [s390x] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true - /@rollup/rollup-linux-x64-gnu@4.59.0: - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} - cpu: [x64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true - /@rollup/rollup-linux-x64-musl@4.59.0: - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} - cpu: [x64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-x64-musl@4.60.1': optional: true - /@rollup/rollup-openbsd-x64@4.59.0: - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true + '@rollup/rollup-openbsd-x64@4.60.1': optional: true - /@rollup/rollup-openharmony-arm64@4.59.0: - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} - cpu: [arm64] - os: [openharmony] - requiresBuild: true - dev: true + '@rollup/rollup-openharmony-arm64@4.60.1': optional: true - /@rollup/rollup-win32-arm64-msvc@4.59.0: - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-win32-arm64-msvc@4.60.1': optional: true - /@rollup/rollup-win32-ia32-msvc@4.59.0: - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-win32-ia32-msvc@4.60.1': optional: true - /@rollup/rollup-win32-x64-gnu@4.59.0: - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-win32-x64-gnu@4.60.1': optional: true - /@rollup/rollup-win32-x64-msvc@4.59.0: - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - /@types/babel__core@7.20.5: - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 - dev: true - /@types/babel__generator@7.27.0: - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@types/babel__generator@7.27.0': dependencies: '@babel/types': 7.29.0 - dev: true - /@types/babel__template@7.4.4: - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 - dev: true - /@types/babel__traverse@7.28.0: - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/babel__traverse@7.28.0': dependencies: '@babel/types': 7.29.0 - dev: true - /@types/cookie@0.6.0: - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - dev: true + '@types/estree@1.0.8': {} - /@types/estree@1.0.8: - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - dev: true - - /@types/hoist-non-react-statics@3.3.7(@types/react@18.2.37): - resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} - peerDependencies: - '@types/react': '*' + '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.28)': dependencies: - '@types/react': 18.2.37 + '@types/react': 18.3.28 hoist-non-react-statics: 3.3.2 - dev: false - /@types/json-schema@7.0.15: - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - dev: true + '@types/json-schema@7.0.15': {} - /@types/prop-types@15.7.15: - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/prop-types@15.7.15': {} - /@types/react-beautiful-dnd@13.1.8: - resolution: {integrity: sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==} + '@types/react-beautiful-dnd@13.1.8': dependencies: - '@types/react': 18.2.37 - dev: true + '@types/react': 18.3.28 - /@types/react-dom@18.2.15: - resolution: {integrity: sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==} + '@types/react-dom@18.3.7(@types/react@18.3.28)': dependencies: - '@types/react': 18.2.37 - dev: true + '@types/react': 18.3.28 - /@types/react-redux@7.1.34: - resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==} + '@types/react-redux@7.1.34': dependencies: - '@types/hoist-non-react-statics': 3.3.7(@types/react@18.2.37) - '@types/react': 18.2.37 + '@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.28) + '@types/react': 18.3.28 hoist-non-react-statics: 3.3.2 redux: 4.2.1 - dev: false - /@types/react@18.2.37: - resolution: {integrity: sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==} + '@types/react@18.3.28': dependencies: '@types/prop-types': 15.7.15 - '@types/scheduler': 0.26.0 csstype: 3.2.3 - /@types/scheduler@0.26.0: - resolution: {integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==} + '@types/semver@7.7.1': {} - /@types/semver@7.7.1: - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - dev: true + '@types/statuses@2.0.6': {} - /@types/statuses@2.0.6: - resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - dev: true - - /@types/tough-cookie@4.0.5: - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - dev: true - - /@typescript-eslint/eslint-plugin@6.13.2(@typescript-eslint/parser@6.13.2)(eslint@8.54.0)(typescript@5.2.2): - resolution: {integrity: sha512-3+9OGAWHhk4O1LlcwLBONbdXsAhLjyCFogJY/cWy2lxdVJ2JrcTF2pTGMaLl2AE7U1l31n8Py4a8bx5DLf/0dQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.13.2(eslint@8.54.0)(typescript@5.2.2) - '@typescript-eslint/scope-manager': 6.13.2 - '@typescript-eslint/type-utils': 6.13.2(eslint@8.54.0)(typescript@5.2.2) - '@typescript-eslint/utils': 6.13.2(eslint@8.54.0)(typescript@5.2.2) - '@typescript-eslint/visitor-keys': 6.13.2 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 8.54.0 + eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - supports-color - dev: true - /@typescript-eslint/parser@6.13.2(eslint@8.54.0)(typescript@5.2.2): - resolution: {integrity: sha512-MUkcC+7Wt/QOGeVlM8aGGJZy1XV5YKjTpq9jK6r6/iLsGXhBVaGP5N0UYvFsu9BFlSpwY9kMretzdBH01rkRXg==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 6.13.2 - '@typescript-eslint/types': 6.13.2 - '@typescript-eslint/typescript-estree': 6.13.2(typescript@5.2.2) - '@typescript-eslint/visitor-keys': 6.13.2 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 8.54.0 - typescript: 5.2.2 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - supports-color - dev: true - /@typescript-eslint/scope-manager@6.13.2: - resolution: {integrity: sha512-CXQA0xo7z6x13FeDYCgBkjWzNqzBn8RXaE3QVQVIUm74fWJLkJkaHmHdKStrxQllGh6Q4eUGyNpMe0b1hMkXFA==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/scope-manager@6.21.0': dependencies: - '@typescript-eslint/types': 6.13.2 - '@typescript-eslint/visitor-keys': 6.13.2 - dev: true + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 - /@typescript-eslint/type-utils@6.13.2(eslint@8.54.0)(typescript@5.2.2): - resolution: {integrity: sha512-Qr6ssS1GFongzH2qfnWKkAQmMUyZSyOr0W54nZNU1MDfo+U4Mv3XveeLZzadc/yq8iYhQZHYT+eoXJqnACM1tw==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 6.13.2(typescript@5.2.2) - '@typescript-eslint/utils': 6.13.2(eslint@8.54.0)(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) debug: 4.4.3 - eslint: 8.54.0 - ts-api-utils: 1.4.3(typescript@5.2.2) - typescript: 5.2.2 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - supports-color - dev: true - /@typescript-eslint/types@6.13.2: - resolution: {integrity: sha512-7sxbQ+EMRubQc3wTfTsycgYpSujyVbI1xw+3UMRUcrhSy+pN09y/lWzeKDbvhoqcRbHdc+APLs/PWYi/cisLPg==} - engines: {node: ^16.0.0 || >=18.0.0} - dev: true + '@typescript-eslint/types@6.21.0': {} - /@typescript-eslint/typescript-estree@6.13.2(typescript@5.2.2): - resolution: {integrity: sha512-SuD8YLQv6WHnOEtKv8D6HZUzOub855cfPnPMKvdM/Bh1plv1f7Q/0iFUDLKKlxHcEstQnaUU4QZskgQq74t+3w==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 6.13.2 - '@typescript-eslint/visitor-keys': 6.13.2 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 + minimatch: 9.0.3 semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - supports-color - dev: true - /@typescript-eslint/utils@6.13.2(eslint@8.54.0)(typescript@5.2.2): - resolution: {integrity: sha512-b9Ptq4eAZUym4idijCRzl61oPCwwREcfDI8xGk751Vhzig5fFZR9CyzDz4Sp/nxSLBYxUPyh4QdIDqWykFhNmQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.54.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 6.13.2 - '@typescript-eslint/types': 6.13.2 - '@typescript-eslint/typescript-estree': 6.13.2(typescript@5.2.2) - eslint: 8.54.0 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 semver: 7.7.4 transitivePeerDependencies: - supports-color - typescript - dev: true - /@typescript-eslint/visitor-keys@6.13.2: - resolution: {integrity: sha512-OGznFs0eAQXJsp+xSd6k/O1UbFi/K/L7WjqeRoFE7vadjAF9y0uppXhYNQNEqygjou782maGClOoZwPqF0Drlw==} - engines: {node: ^16.0.0 || >=18.0.0} + '@typescript-eslint/visitor-keys@6.21.0': dependencies: - '@typescript-eslint/types': 6.13.2 + '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 - dev: true - /@ungap/structured-clone@1.3.0: - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - dev: true + '@ungap/structured-clone@1.3.0': {} - /@vitejs/plugin-react@4.2.1(vite@5.0.8): - resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.0.8 + react-refresh: 0.17.0 + vite: 5.4.21 transitivePeerDependencies: - supports-color - dev: true - /acorn-jsx@5.3.2(acorn@8.16.0): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 - dev: true - /acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: true + acorn@8.16.0: {} - /ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - dev: true - /ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - dev: true + ansi-regex@5.0.1: {} - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - dev: true - /antd@5.10.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-qeyfMhcDK2QeuKaC/OCjNCPrJb+3vrBHvVK2swRHsxJvKFSpRerMCShOn/I3CXogrVJazPMluGhy0FQlcHQ4pw==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + antd@5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@ant-design/colors': 7.2.1 - '@ant-design/cssinjs': 1.24.0(react-dom@18.2.0)(react@18.2.0) - '@ant-design/icons': 5.2.6(react-dom@18.2.0)(react@18.2.0) - '@ant-design/react-slick': 1.0.2(react@18.2.0) - '@babel/runtime': 7.28.6 - '@ctrl/tinycolor': 3.6.1 - '@rc-component/color-picker': 1.4.1(react-dom@18.2.0)(react@18.2.0) - '@rc-component/mutate-observer': 1.1.0(react-dom@18.2.0)(react@18.2.0) - '@rc-component/tour': 1.10.0(react-dom@18.2.0)(react@18.2.0) - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/cssinjs-utils': 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/fast-color': 2.0.6 + '@ant-design/icons': 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/react-slick': 1.1.2(react@18.3.1) + '@babel/runtime': 7.29.2 + '@rc-component/color-picker': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mutate-observer': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/qrcode': 1.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tour': 1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 copy-to-clipboard: 3.3.3 - dayjs: 1.11.19 - qrcode.react: 3.2.0(react@18.2.0) - rc-cascader: 3.18.1(react-dom@18.2.0)(react@18.2.0) - rc-checkbox: 3.1.0(react-dom@18.2.0)(react@18.2.0) - rc-collapse: 3.7.3(react-dom@18.2.0)(react@18.2.0) - rc-dialog: 9.3.4(react-dom@18.2.0)(react@18.2.0) - rc-drawer: 6.5.2(react-dom@18.2.0)(react@18.2.0) - rc-dropdown: 4.1.0(react-dom@18.2.0)(react@18.2.0) - rc-field-form: 1.38.2(react-dom@18.2.0)(react@18.2.0) - rc-image: 7.3.2(react-dom@18.2.0)(react@18.2.0) - rc-input: 1.2.1(react-dom@18.2.0)(react@18.2.0) - rc-input-number: 8.1.0(react-dom@18.2.0)(react@18.2.0) - rc-mentions: 2.8.0(react-dom@18.2.0)(react@18.2.0) - rc-menu: 9.12.4(react-dom@18.2.0)(react@18.2.0) - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-notification: 5.2.0(react-dom@18.2.0)(react@18.2.0) - rc-pagination: 3.6.1(react-dom@18.2.0)(react@18.2.0) - rc-picker: 3.14.7(dayjs@1.11.19)(react-dom@18.2.0)(react@18.2.0) - rc-progress: 3.5.1(react-dom@18.2.0)(react@18.2.0) - rc-rate: 2.12.0(react-dom@18.2.0)(react@18.2.0) - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-segmented: 2.2.2(react-dom@18.2.0)(react@18.2.0) - rc-select: 14.9.2(react-dom@18.2.0)(react@18.2.0) - rc-slider: 10.3.1(react-dom@18.2.0)(react@18.2.0) - rc-steps: 6.0.1(react-dom@18.2.0)(react@18.2.0) - rc-switch: 4.1.0(react-dom@18.2.0)(react@18.2.0) - rc-table: 7.34.4(react-dom@18.2.0)(react@18.2.0) - rc-tabs: 12.12.1(react-dom@18.2.0)(react@18.2.0) - rc-textarea: 1.4.0(react-dom@18.2.0)(react@18.2.0) - rc-tooltip: 6.1.3(react-dom@18.2.0)(react@18.2.0) - rc-tree: 5.7.12(react-dom@18.2.0)(react@18.2.0) - rc-tree-select: 5.13.0(react-dom@18.2.0)(react@18.2.0) - rc-upload: 4.3.6(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + dayjs: 1.11.20 + rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-drawer: 7.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-field-form: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-image: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-input-number: 9.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-mentions: 2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-picker: 4.11.3(dayjs@1.11.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-segmented: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-slider: 11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-steps: 6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-switch: 4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-table: 7.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tabs: 15.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-textarea: 1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tooltip: 6.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree-select: 5.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-upload: 4.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 throttle-debounce: 5.0.2 transitivePeerDependencies: - date-fns - luxon - moment - dev: false - /any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - dev: true + any-promise@1.3.0: {} - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: true + picomatch: 2.3.2 - /arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - dev: true + arg@5.0.2: {} - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true + argparse@2.0.1: {} - /array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 is-array-buffer: 3.0.5 - dev: true - /array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} + array-includes@3.1.9: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -1474,51 +3314,41 @@ packages: get-intrinsic: 1.3.0 is-string: 1.1.1 math-intrinsics: 1.1.0 - dev: true - /array-tree-filter@2.1.0: - resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} - dev: false + array-union@2.1.0: {} - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: true + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 - /array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} + array.prototype.flat@1.3.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 - dev: true - /array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} + array.prototype.flatmap@1.3.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.24.1 es-shim-unscopables: 1.1.0 - dev: true - /array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} + array.prototype.tosorted@1.1.4: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.24.1 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 - dev: true - /arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} + arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 call-bind: 1.0.8 @@ -1527,150 +3357,90 @@ packages: es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - dev: true - /async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - dev: true + async-function@1.0.0: {} - /async-validator@4.2.5: - resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - dev: false + asynckit@0.4.0: {} - /asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false - - /autoprefixer@10.4.16(postcss@8.4.32): - resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 + autoprefixer@10.4.27(postcss@8.5.8): dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001776 - fraction.js: 4.3.7 - normalize-range: 0.1.2 + browserslist: 4.28.2 + caniuse-lite: 1.0.30001784 + fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.4.32 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - dev: true - /available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - dev: true - /axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.14.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - dev: false - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true + balanced-match@1.0.2: {} - /baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} - engines: {node: '>=6.0.0'} - hasBin: true - dev: true + baseline-browser-mapping@2.10.13: {} - /binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - dev: true + binary-extensions@2.3.0: {} - /brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - dev: true - /braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + brace-expansion@2.0.3: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: dependencies: fill-range: 7.1.1 - dev: true - /browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001776 - electron-to-chromium: 1.5.307 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - dev: true + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001784 + electron-to-chromium: 1.5.331 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) - /call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - /call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} + call-bind@1.0.8: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 get-intrinsic: 1.3.0 set-function-length: 1.2.2 - dev: true - /call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - dev: true - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - dev: true + callsites@3.1.0: {} - /camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - dev: true + camelcase-css@2.0.1: {} - /camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - dev: true + camelcase@6.3.0: {} - /caniuse-lite@1.0.30001776: - resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} - dev: true + caniuse-lite@1.0.30001784: {} - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - dev: true - /chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 braces: 3.0.3 @@ -1681,219 +3451,124 @@ packages: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.3 - dev: true - /classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - dev: false + classnames@2.5.1: {} - /cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - dev: true + cli-width@4.1.0: {} - /cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - dev: true - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + color-convert@2.0.1: dependencies: color-name: 1.1.4 - dev: true - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true + color-name@1.1.4: {} - /combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - dev: false - /commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - dev: true + commander@14.0.3: {} - /commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - dev: true + commander@4.1.1: {} - /compute-scroll-into-view@3.1.1: - resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - dev: false + compute-scroll-into-view@3.1.1: {} - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true + concat-map@0.0.1: {} - /convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - dev: true + convert-source-map@2.0.0: {} - /cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - dev: true + cookie@1.1.1: {} - /copy-to-clipboard@3.3.3: - resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 - dev: false - /cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - dev: true - /css-box-model@1.2.1: - resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + css-box-model@1.2.1: dependencies: tiny-invariant: 1.3.3 - dev: false - /cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - dev: true + cssesc@3.0.0: {} - /csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + csstype@3.2.3: {} - /data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 - dev: true - /data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} + data-view-byte-length@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 - dev: true - /data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} + data-view-byte-offset@1.0.1: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 - dev: true - /dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - dev: false + dayjs@1.11.20: {} - /debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + debug@4.4.3: dependencies: ms: 2.1.3 - dev: true - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true + deep-is@0.1.4: {} - /define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 - dev: true - /define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - dev: true - /delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dev: false + delayed-stream@1.0.0: {} - /didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - dev: true + didyoumean@1.2.2: {} - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 - dev: true - /dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dev: true + dlv@1.1.3: {} - /doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} + doctrine@2.1.0: dependencies: esutils: 2.0.3 - dev: true - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} + doctrine@3.0.0: dependencies: esutils: 2.0.3 - dev: true - /dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - /electron-to-chromium@1.5.307: - resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} - dev: true + electron-to-chromium@1.5.331: {} - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true + emoji-regex@8.0.0: {} - /es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 @@ -1949,19 +3624,12 @@ packages: typed-array-length: 1.0.7 unbox-primitive: 1.1.0 which-typed-array: 1.1.20 - dev: true - /es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} + es-define-property@1.0.1: {} - /es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} + es-errors@1.3.0: {} - /es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} - engines: {node: '>= 0.4'} + es-iterator-helpers@1.3.1: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -1978,139 +3646,100 @@ packages: has-symbols: 1.1.0 internal-slot: 1.1.0 iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 safe-array-concat: 1.1.3 - dev: true - /es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 - /es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: dependencies: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 - /es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} + es-shim-unscopables@1.1.0: dependencies: hasown: 2.0.2 - dev: true - /es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} + es-to-primitive@1.3.0: dependencies: is-callable: 1.2.7 is-date-object: 1.1.0 is-symbol: 1.1.1 - dev: true - /esbuild@0.19.12: - resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true + esbuild@0.21.5: optionalDependencies: - '@esbuild/aix-ppc64': 0.19.12 - '@esbuild/android-arm': 0.19.12 - '@esbuild/android-arm64': 0.19.12 - '@esbuild/android-x64': 0.19.12 - '@esbuild/darwin-arm64': 0.19.12 - '@esbuild/darwin-x64': 0.19.12 - '@esbuild/freebsd-arm64': 0.19.12 - '@esbuild/freebsd-x64': 0.19.12 - '@esbuild/linux-arm': 0.19.12 - '@esbuild/linux-arm64': 0.19.12 - '@esbuild/linux-ia32': 0.19.12 - '@esbuild/linux-loong64': 0.19.12 - '@esbuild/linux-mips64el': 0.19.12 - '@esbuild/linux-ppc64': 0.19.12 - '@esbuild/linux-riscv64': 0.19.12 - '@esbuild/linux-s390x': 0.19.12 - '@esbuild/linux-x64': 0.19.12 - '@esbuild/netbsd-x64': 0.19.12 - '@esbuild/openbsd-x64': 0.19.12 - '@esbuild/sunos-x64': 0.19.12 - '@esbuild/win32-arm64': 0.19.12 - '@esbuild/win32-ia32': 0.19.12 - '@esbuild/win32-x64': 0.19.12 - dev: true + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 - /escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - dev: true + escalade@3.2.0: {} - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - dev: true + escape-string-regexp@4.0.0: {} - /eslint-plugin-react-hooks@4.6.0(eslint@8.54.0): - resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): dependencies: - eslint: 8.54.0 - dev: true + eslint: 8.57.1 - /eslint-plugin-react@7.33.2(eslint@8.54.0): - resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + eslint-plugin-react@7.37.5(eslint@8.57.1): dependencies: array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 8.54.0 + es-iterator-helpers: 1.3.1 + eslint: 8.57.1 estraverse: 5.3.0 + hasown: 2.0.2 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 - object.hasown: 1.1.4 object.values: 1.2.1 prop-types: 15.8.1 resolve: 2.0.0-next.6 semver: 6.3.1 string.prototype.matchall: 4.0.12 - dev: true + string.prototype.repeat: 1.0.0 - /eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@7.2.2: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 - dev: true - /eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true + eslint-visitor-keys@3.4.3: {} - /eslint@8.54.0: - resolution: {integrity: sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true + eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.54.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.54.0 - '@humanwhocodes/config-array': 0.11.14 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.3.0 @@ -2146,176 +3775,98 @@ packages: text-table: 0.2.0 transitivePeerDependencies: - supports-color - dev: true - /espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@9.6.1: dependencies: acorn: 8.16.0 acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 3.4.3 - dev: true - /esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} + esquery@1.7.0: dependencies: estraverse: 5.3.0 - dev: true - /esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 - dev: true - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - dev: true + estraverse@5.3.0: {} - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - dev: true + esutils@2.0.3: {} - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true + fast-deep-equal@3.1.3: {} - /fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.8 - dev: true - /fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dev: true + fast-json-stable-stringify@2.1.0: {} - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true + fast-levenshtein@2.0.6: {} - /fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.1: dependencies: reusify: 1.1.0 - dev: true - /fdir@6.5.0(picomatch@4.0.3): - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - dependencies: - picomatch: 4.0.3 - dev: true + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 - /file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 - dev: true - /fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - dev: true - /find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + find-up@5.0.0: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - dev: true - /flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + flat-cache@3.2.0: dependencies: - flatted: 3.3.4 + flatted: 3.4.2 keyv: 4.5.4 rimraf: 3.0.2 - dev: true - /flatted@3.3.4: - resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} - dev: true + flatted@3.4.2: {} - /follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false + follow-redirects@1.15.11: {} - /for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} + for-each@0.3.5: dependencies: is-callable: 1.2.7 - dev: true - /form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 hasown: 2.0.2 mime-types: 2.1.35 - dev: false - /fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - dev: true + fraction.js@5.3.4: {} - /fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.0 universalify: 2.0.1 - dev: true - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true + fs.realpath@1.0.0: {} - /fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true + fsevents@2.3.3: optional: true - /function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function-bind@1.1.2: {} - /function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} + function.prototype.name@1.1.8: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -2323,30 +3874,16 @@ packages: functions-have-names: 1.2.3 hasown: 2.0.2 is-callable: 1.2.7 - dev: true - /functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - dev: true + functions-have-names@1.2.3: {} - /generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - dev: true + generator-function@2.0.1: {} - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - dev: true + gensync@1.0.0-beta.2: {} - /get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - dev: true + get-caller-file@2.0.5: {} - /get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -2359,39 +3896,26 @@ packages: hasown: 2.0.2 math-intrinsics: 1.1.0 - /get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - /get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 get-intrinsic: 1.3.0 - dev: true - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - dev: true - /glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 - dev: true - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + glob@7.2.3: dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -2399,26 +3923,17 @@ packages: minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 - dev: true - /globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + globals@13.24.0: dependencies: type-fest: 0.20.2 - dev: true - /globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 gopd: 1.2.0 - dev: true - /globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + globby@11.1.0: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 @@ -2426,29 +3941,16 @@ packages: ignore: 5.3.2 merge2: 1.4.1 slash: 3.0.0 - dev: true - /gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + gopd@1.2.0: {} - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true + graceful-fs@4.2.11: {} - /graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - dev: true + graphemer@1.4.0: {} - /graphql@16.13.0: - resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - dev: true + graphql@16.13.2: {} - /handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -2456,329 +3958,192 @@ packages: wordwrap: 1.0.0 optionalDependencies: uglify-js: 3.19.3 - dev: true - /has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - dev: true + has-bigints@1.1.0: {} - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - dev: true + has-flag@4.0.0: {} - /has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - dev: true - /has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} + has-proto@1.2.0: dependencies: dunder-proto: 1.0.1 - dev: true - /has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} + has-symbols@1.1.0: {} - /has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 - /hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} + hasown@2.0.2: dependencies: function-bind: 1.1.2 - /headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - dev: true + headers-polyfill@4.0.3: {} - /hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 - dev: false - /html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 - dev: false - /i18next-browser-languagedetector@7.2.1: - resolution: {integrity: sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==} + i18next-browser-languagedetector@7.2.2: dependencies: - '@babel/runtime': 7.28.6 - dev: false + '@babel/runtime': 7.29.2 - /i18next@23.11.4: - resolution: {integrity: sha512-CCUjtd5TfaCl+mLUzAA0uPSN+AVn4fP/kWCYt/hocPUwusTpMVczdrRyOBUwk6N05iH40qiKx6q1DoNJtBIwdg==} + i18next@23.16.8: dependencies: - '@babel/runtime': 7.28.6 - dev: false + '@babel/runtime': 7.29.2 - /ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - dev: true + ignore@5.3.2: {} - /import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 - dev: true - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - dev: true + imurmurhash@0.1.4: {} - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inflight@1.0.6: dependencies: once: 1.4.0 wrappy: 1.0.2 - dev: true - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true + inherits@2.0.4: {} - /internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 - dev: true - /is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 get-intrinsic: 1.3.0 - dev: true - /is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 call-bound: 1.0.4 get-proto: 1.0.1 has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 - dev: true - /is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} + is-bigint@1.1.0: dependencies: has-bigints: 1.1.0 - dev: true - /is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - dev: true - /is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - dev: true - /is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - dev: true + is-callable@1.2.7: {} - /is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 - dev: true - /is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 get-intrinsic: 1.3.0 is-typed-array: 1.1.15 - dev: true - /is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} + is-date-object@1.1.0: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - dev: true - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - dev: true + is-extglob@2.1.1: {} - /is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} + is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 - dev: true - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - dev: true + is-fullwidth-code-point@3.0.0: {} - /is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 generator-function: 2.0.1 get-proto: 1.0.1 has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 - dev: true - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - dev: true - /is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - dev: true + is-map@2.0.3: {} - /is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - dev: true + is-negative-zero@2.0.3: {} - /is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} - dev: true + is-node-process@1.2.0: {} - /is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - dev: true - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: true + is-number@7.0.0: {} - /is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - dev: true + is-path-inside@3.0.3: {} - /is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 hasown: 2.0.2 - dev: true - /is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - dev: true + is-set@2.0.3: {} - /is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} + is-shared-array-buffer@1.0.4: dependencies: call-bound: 1.0.4 - dev: true - /is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} + is-string@1.1.1: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - dev: true - /is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} + is-symbol@1.1.1: dependencies: call-bound: 1.0.4 has-symbols: 1.1.0 safe-regex-test: 1.1.0 - dev: true - /is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} + is-typed-array@1.1.15: dependencies: which-typed-array: 1.1.20 - dev: true - /is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - dev: true + is-weakmap@2.0.2: {} - /is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} + is-weakref@1.1.1: dependencies: call-bound: 1.0.4 - dev: true - /is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} + is-weakset@2.0.4: dependencies: call-bound: 1.0.4 get-intrinsic: 1.3.0 - dev: true - /isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - dev: true + isarray@2.0.5: {} - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true + isexe@2.0.0: {} - /iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 es-object-atoms: 1.1.1 @@ -2786,276 +4151,157 @@ packages: get-proto: 1.0.1 has-symbols: 1.1.0 set-function-name: 2.0.2 - dev: true - /jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true - dev: true + jiti@1.21.7: {} - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@4.0.0: {} - /js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true + js-yaml@4.1.1: dependencies: argparse: 2.0.1 - dev: true - /jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - dev: true + jsesc@3.1.0: {} - /json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true + json-buffer@3.0.1: {} - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true + json-schema-traverse@0.4.1: {} - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - dev: true + json-stable-stringify-without-jsonify@1.0.1: {} - /json2mq@0.2.0: - resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + json2mq@0.2.0: dependencies: string-convert: 0.2.1 - dev: false - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - dev: true + json5@2.2.3: {} - /jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 - dev: true - /jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 array.prototype.flat: 1.3.3 object.assign: 4.1.7 object.values: 1.2.1 - dev: true - /keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 - dev: true - /levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - dev: true - /lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - dev: true + lilconfig@3.1.3: {} - /lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - dev: true + lines-and-columns@1.2.4: {} - /lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true - - /locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 - dev: true - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true + lodash.merge@4.6.2: {} - /loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 - /lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 - dev: true - /math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} + math-intrinsics@1.1.0: {} - /memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - dev: false + memoize-one@5.2.1: {} - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: true + merge2@1.4.1: {} - /micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 - dev: true + picomatch: 2.3.2 - /mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - dev: false + mime-db@1.52.0: {} - /mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 - dev: false - /minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 - dev: true + brace-expansion: 1.1.13 - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true - - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true - - /msw@2.6.6(typescript@5.2.2): - resolution: {integrity: sha512-npfIIVRHKQX3Lw4aLWX4wBh+lQwpqdZNyJYB5K/+ktK8NhtkdsTxGK7WDrgknozcVyRI7TOqY6yBS9j2FTR+YQ==} - engines: {node: '>=18'} - hasBin: true - requiresBuild: true - peerDependencies: - typescript: '>= 4.8.x' - peerDependenciesMeta: - typescript: - optional: true + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.3 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + msw@2.12.14(typescript@5.9.3): dependencies: - '@bundled-es-modules/cookie': 2.0.1 - '@bundled-es-modules/statuses': 1.0.1 - '@bundled-es-modules/tough-cookie': 0.1.6 '@inquirer/confirm': 5.1.21 - '@mswjs/interceptors': 0.37.6 + '@mswjs/interceptors': 0.41.3 '@open-draft/deferred-promise': 2.2.0 - '@open-draft/until': 2.1.0 - '@types/cookie': 0.6.0 '@types/statuses': 2.0.6 - chalk: 4.1.2 - graphql: 16.13.0 + cookie: 1.1.1 + graphql: 16.13.2 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 strict-event-emitter: 0.5.1 - type-fest: 4.41.0 - typescript: 5.2.2 + tough-cookie: 6.0.1 + type-fest: 5.5.0 + until-async: 3.0.2 yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - '@types/node' - dev: true - /mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - dev: true + mute-stream@2.0.0: {} - /mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 - dev: true - /nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: true + nanoid@3.3.11: {} - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - dev: true + natural-compare@1.4.0: {} - /neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - dev: true + neo-async@2.6.2: {} - /node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 es-errors: 1.3.0 object.entries: 1.1.9 semver: 6.3.1 - dev: true - /node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - dev: true + node-releases@2.0.37: {} - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: true + normalize-path@3.0.0: {} - /normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - dev: true + object-assign@4.1.1: {} - /object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + object-hash@3.0.0: {} - /object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - dev: true + object-inspect@1.13.4: {} - /object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - dev: true + object-keys@1.1.1: {} - /object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - dev: true - - /object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} + object.assign@4.1.7: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -3063,69 +4309,43 @@ packages: es-object-atoms: 1.1.1 has-symbols: 1.1.0 object-keys: 1.1.1 - dev: true - /object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} + object.entries@1.1.9: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 - dev: true - /object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} + object.fromentries@2.0.8: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 es-abstract: 1.24.1 es-object-atoms: 1.1.1 - dev: true - /object.hasown@1.1.4: - resolution: {integrity: sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg==} - engines: {node: '>= 0.4'} - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - dev: true - - /object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} + object.values@1.2.1: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 - dev: true - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + once@1.4.0: dependencies: wrappy: 1.0.2 - dev: true - /openapi-typescript-codegen@0.30.0(@types/json-schema@7.0.15): - resolution: {integrity: sha512-NO24vrOYEEREkuEwtLemXiV0/3wUj1HvS+0UuAinVNWKJOyNlXTj5hehdW9Dyob4u5YGrRG9dc9TBZW7/UszGw==} - hasBin: true + openapi-typescript-codegen@0.30.0(@types/json-schema@7.0.15): dependencies: '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) camelcase: 6.3.0 commander: 14.0.3 fs-extra: 11.3.4 - handlebars: 4.7.8 + handlebars: 4.7.9 transitivePeerDependencies: - '@types/json-schema' - dev: true - /optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + optionator@0.9.4: dependencies: deep-is: 0.1.4 fast-levenshtein: 2.0.6 @@ -3133,866 +4353,502 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 word-wrap: 1.2.5 - dev: true - /outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - dev: true + outvariant@1.4.3: {} - /own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 - dev: true - /p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - dev: true - /p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + p-locate@5.0.0: dependencies: p-limit: 3.1.0 - dev: true - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + parent-module@1.0.1: dependencies: callsites: 3.1.0 - dev: true - /path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - dev: true + path-exists@4.0.0: {} - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: true + path-is-absolute@1.0.1: {} - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: true + path-key@3.1.1: {} - /path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true + path-parse@1.0.7: {} - /path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - dev: true + path-to-regexp@6.3.0: {} - /path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - dev: true + path-type@4.0.0: {} - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - dev: true + picocolors@1.1.1: {} - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: true + picomatch@2.3.2: {} - /picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - dev: true + picomatch@4.0.4: {} - /pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - dev: true + pify@2.3.0: {} - /pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - dev: true + pirates@4.0.7: {} - /possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - dev: true + possible-typed-array-names@1.1.0: {} - /postcss-import@15.1.0(postcss@8.4.32): - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 + postcss-import@15.1.0(postcss@8.5.8): dependencies: - postcss: 8.4.32 + postcss: 8.5.8 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.11 - dev: true - /postcss-js@4.1.0(postcss@8.4.32): - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 + postcss-js@4.1.0(postcss@8.5.8): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.32 - dev: true + postcss: 8.5.8 - /postcss-load-config@4.0.2(postcss@8.4.32): - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8): dependencies: lilconfig: 3.1.3 - postcss: 8.4.32 - yaml: 2.8.2 - dev: true + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.8 - /postcss-nested@6.2.0(postcss@8.4.32): - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 + postcss-nested@6.2.0(postcss@8.5.8): dependencies: - postcss: 8.4.32 + postcss: 8.5.8 postcss-selector-parser: 6.1.2 - dev: true - /postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - dev: true - /postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - dev: true + postcss-value-parser@4.2.0: {} - /postcss@8.4.32: - resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==} - engines: {node: ^10 || ^12 || >=14} + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - dev: true - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - dev: true + prelude-ls@1.2.1: {} - /prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false + proxy-from-env@2.1.0: {} - /psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + raf-schd@4.0.3: {} + + rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - punycode: 2.3.1 - dev: true - - /punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - dev: true - - /qrcode.react@3.2.0(react@18.2.0): - resolution: {integrity: sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - dependencies: - react: 18.2.0 - dev: false - - /querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - dev: true - - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true - - /raf-schd@4.0.3: - resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} - dev: false - - /rc-cascader@3.18.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-M7Xr5Fs/E87ZGustfObtBYQjsvBCET0UX2JYXB2GmOP+2fsZgjaRGXK+CJBmmWXQ6o4OFinpBQBXG4wJOQ5MEg==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' - dependencies: - '@babel/runtime': 7.28.6 - array-tree-filter: 2.1.0 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-select: 14.9.2(react-dom@18.2.0)(react@18.2.0) - rc-tree: 5.7.12(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-checkbox@3.1.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-PAwpJFnBa3Ei+5pyqMMXdcKYKNBMS+TvSDiLdDnARnMJHC8ESxwPfm4Ao1gJiKtWLdmGfigascnCpwrHFgoOBQ==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-checkbox@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-collapse@3.7.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-60FJcdTRn0X5sELF18TANwtVi7FtModq649H11mYF1jh83DniMoM4MqY627sEKRCTm4+WXfGDcB7hY5oW6xhyw==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-collapse@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-dialog@9.3.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-975X3018GhR+EjZFbxA2Z57SX5rnu0G0/OxFgMMvZK4/hQWEm3MHaNvP4wXpxYDoJsp+xUvVW+GB9CMMCm81jA==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-dialog@9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-drawer@6.5.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-QckxAnQNdhh4vtmKN0ZwDf3iakO83W9eZcSKWYYTDv4qcD2fHhRAZJJ/OE6v2ZlQ2kSqCJX5gYssF4HJFvsEPQ==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-drawer@7.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-dropdown@4.1.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-VZjMunpBdlVzYpEdJSaV7WM7O0jf8uyDjirxXLZRNZ+tAC+NzD3PXPEtliFwGzVwBBdCmGuSqiS9DWcOLxQ9tw==} - peerDependencies: - react: '>=16.11.0' - react-dom: '>=16.11.0' + rc-dropdown@4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-field-form@1.38.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-O83Oi1qPyEv31Sg+Jwvsj6pXc8uQI2BtIAkURr5lvEYHVggXJhdU/nynK8wY1gbw0qR48k731sN5ON4egRCROA==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - async-validator: 4.2.5 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + '@babel/runtime': 7.29.2 + '@rc-component/async-validator': 5.1.0 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-image@7.3.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-ICEF6SWv9YKhDXxy1vrXcmf0TVvEcQWIww5Yg+f+mn7e4oGX7FNP4+FExwMjNO5UHBEuWrigbGhlCgI6yZZ1jg==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-image@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-dialog: 9.3.4(react-dom@18.2.0)(react@18.2.0) - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-input-number@8.1.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-bdHgduOxuN0lrhzgPmoKbhRD4GLIzVcddVz972/JHPHr7oLwPX5xDb9w4bXhuMzyT2VzQy7nggRCfH3yAl09oA==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-input-number@9.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/mini-decimal': 1.1.0 + '@babel/runtime': 7.29.2 + '@rc-component/mini-decimal': 1.1.3 classnames: 2.5.1 - rc-input: 1.2.1(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-input@1.2.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-nQRmBvEFoGi+SNRDavccZ8ueyhFgmxkWqIt4aDyuNJgUZF12HJKIwDhAafUM7N+g7PyuW9FH3pf3zPHzdiCWbA==} - peerDependencies: - react: '>=16.0.0' - react-dom: '>=16.0.0' + rc-input@1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-mentions@2.8.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-LBMkO6bSGhEvS1CvMK978qGN82tI+mzk7l/uTiQJH+UDiwpvq+pxK4DxU5b6Q1T5LW6bn2pSua9RaZKZrDoBOw==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-mentions@2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-input: 1.2.1(react-dom@18.2.0)(react@18.2.0) - rc-menu: 9.12.4(react-dom@18.2.0)(react@18.2.0) - rc-textarea: 1.4.0(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-textarea: 1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-menu@9.12.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-t2NcvPLV1mFJzw4F21ojOoRVofK2rWhpKPx69q2raUsiHPDP6DDevsBILEYdsIegqBeSXoWs2bf6CueBKg3BFg==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-menu@9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-overflow: 1.5.0(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-motion@2.9.5(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-motion@2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-notification@5.2.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-HwUSypEW4mfOpiakJ7dm6TAKf+3zuSR2xm0I0XMes493rtA3n4EVMvQyldrp23hUwCE3RFj8oncyU1E8iNC4ag==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-notification@5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-overflow@1.5.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-overflow@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-pagination@3.6.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-R/sUnKKXx1Nm4kZfUKS3YKa7yEPF1ZkVB/AynQaHt+nMER7h9wPTfliDJFdYo+RM/nk2JD4Yc5QpUq8fIQHeug==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-pagination@5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-picker@3.14.7(dayjs@1.11.19)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-+craFcClAOwu4R7lSlaiTAZRY4cWPgtE0+yji9stQkQR28C7WGTrZcyiq5AD7xfhXNV+82QmoJ8Aqg3duDYF6A==} - engines: {node: '>=8.x'} - peerDependencies: - date-fns: '>= 2.x' - dayjs: '>= 1.x' - luxon: '>= 3.x' - moment: '>= 2.x' - react: '>=16.9.0' - react-dom: '>=16.9.0' - peerDependenciesMeta: - date-fns: - optional: true - dayjs: - optional: true - luxon: - optional: true - moment: - optional: true + rc-picker@4.11.3(dayjs@1.11.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - dayjs: 1.11.19 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + dayjs: 1.11.20 - /rc-progress@3.5.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-V6Amx6SbLRwPin/oD+k1vbPrO8+9Qf8zW1T8A7o83HdNafEVvAxPV5YsgtKFP+Ud5HghLj33zKOcEHrcrUGkfw==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-rate@2.12.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-g092v5iZCdVzbjdn28FzvWebK2IutoVoiTeqoLTj9WM7SjA/gOJIw5/JFZMRyJYYVe1jLAU2UhAfstIpCNRozg==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-rate@2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-resize-observer@1.4.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-resize-observer@1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) resize-observer-polyfill: 1.5.1 - dev: false - /rc-segmented@2.2.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Mq52M96QdHMsNdE/042ibT5vkcGcD5jxKp7HgPC2SRofpia99P5fkfHy1pEaajLMF/kj0+2Lkq1UZRvqzo9mSA==} - peerDependencies: - react: '>=16.0.0' - react-dom: '>=16.0.0' + rc-segmented@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-select@14.9.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-VQ15sRFgPURHb8ZcZNSDtb2rAw3+C9xlL0nDziwNHTEW1KvEpZ8y+0v5w24X/Bpl9b3cW1BOyW1F5UqSAq+7Dg==} - engines: {node: '>=8.x'} - peerDependencies: - react: '*' - react-dom: '*' + rc-select@14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-overflow: 1.5.0(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - rc-virtual-list: 3.19.2(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-slider@10.3.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-XszsZLkbjcG9ogQy/zUC0n2kndoKUAnY/Vnk1Go5Gx+JJQBz0Tl15d5IfSiglwBUZPS9vsUJZkfCmkIZSqWbcA==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-slider@11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-steps@6.0.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-steps@6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-switch@4.1.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-switch@4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-table@7.34.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-os+i88Y2AO/6dNkOgJkKSHgXYaZZGnuOEEe+nyaq5IRgvAQNhLysUjXt2objtBeFDEZR8TqXrajwBNRUwunmdw==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-table@7.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/context': 1.4.0(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/context': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - rc-virtual-list: 3.19.2(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-tabs@12.12.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-e10VBjEkECdPl4XZSs9to81SE+mgclBTM7J8/LMsFqmJoi05Tci91bRnmeeDtrcOCx2PuZdJv57XUlC4d8PEIw==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-tabs@15.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-dropdown: 4.1.0(react-dom@18.2.0)(react@18.2.0) - rc-menu: 9.12.4(react-dom@18.2.0)(react@18.2.0) - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-textarea@1.4.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-CiqK+uyoJlnfufbC0kwfHJpfElhQacuDSNyNQ/xGnA/QMaJLDbgmqRT8QmX0T0KD/ws/hy6qqRaGJSsrRR5uiQ==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-textarea@1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-input: 1.2.1(react-dom@18.2.0)(react@18.2.0) - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-tooltip@6.1.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-HMSbSs5oieZ7XddtINUddBLSVgsnlaSb3bZrzzGWjXa7/B7nNedmsuz72s7EWFEro9mNa7RyF3gOXKYqvJiTcQ==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-tooltip@6.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-tree-select@5.13.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-g01JU9EdE7j/9KfDKtmvFqJ7ZDNIYDzkpmAXllbTBFoRNhWJBjW1x/dCZLVG+IdZeIz8SKJkgZzCf1CUZrzV/Q==} - peerDependencies: - react: '*' - react-dom: '*' + rc-tree-select@5.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-select: 14.9.2(react-dom@18.2.0)(react@18.2.0) - rc-tree: 5.7.12(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-tree@5.7.12(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-LXA5nY2hG5koIAlHW5sgXgLpOMz+bFRbnZZ+cCg0tQs4Wv1AmY7EDi1SK7iFXhslYockbqUerQan82jljoaItg==} - engines: {node: '>=10.x'} - peerDependencies: - react: '*' - react-dom: '*' + rc-tree@5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-motion: 2.9.5(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - rc-virtual-list: 3.19.2(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-upload@4.3.6(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Bt7ESeG5tT3IY82fZcP+s0tQU2xmo1W6P3S8NboUUliquJLQYLkUcsaExi3IlBVr43GQMCjo30RA2o0i70+NjA==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-upload@4.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /rc-util@5.44.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-util@5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@babel/runtime': 7.29.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) react-is: 18.3.1 - dev: false - /rc-virtual-list@3.19.2(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} - engines: {node: '>=8.x'} - peerDependencies: - react: '>=16.9.0' - react-dom: '>=16.9.0' + rc-virtual-list@3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-resize-observer: 1.4.3(react-dom@18.2.0)(react@18.2.0) - rc-util: 5.44.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - /react-beautiful-dnd@13.1.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} - deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672' - peerDependencies: - react: ^16.8.5 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-beautiful-dnd@13.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 css-box-model: 1.2.1 memoize-one: 5.2.1 raf-schd: 4.0.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-redux: 7.2.9(react-dom@18.2.0)(react@18.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-redux: 7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux: 4.2.1 - use-memo-one: 1.1.3(react@18.2.0) + use-memo-one: 1.1.3(react@18.3.1) transitivePeerDependencies: - react-native - dev: false - /react-dom@18.2.0(react@18.2.0): - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} - peerDependencies: - react: ^18.2.0 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 - react: 18.2.0 + react: 18.3.1 scheduler: 0.23.2 - dev: false - /react-i18next@15.0.1(i18next@23.11.4)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-NwxLqNM6CLbeGA9xPsjits0EnXdKgCRSS6cgkgOdNcPXqL+1fYNl8fBg1wmnnHvFy812Bt4IWTPE9zjoPmFj3w==} - peerDependencies: - i18next: '>= 23.2.3' - react: '>= 16.8.0' - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true + react-i18next@15.7.4(i18next@23.16.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 23.11.4 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + i18next: 23.16.8 + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.3 - /react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@16.13.1: {} - /react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - dev: false + react-is@17.0.2: {} - /react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - dev: false + react-is@18.3.1: {} - /react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==} - peerDependencies: - react: ^16.8.3 || ^17 || ^18 - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true + react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/react-redux': 7.1.34 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react: 18.3.1 react-is: 17.0.2 - dev: false + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) - /react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} - engines: {node: '>=0.10.0'} - dev: true + react-refresh@0.17.0: {} - /react-router-dom@6.30.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@remix-run/router': 1.23.2 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-router: 6.30.3(react@18.2.0) - dev: false + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) - /react-router@6.30.3(react@18.2.0): - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' + react-router@6.30.3(react@18.3.1): dependencies: '@remix-run/router': 1.23.2 - react: 18.2.0 - dev: false + react: 18.3.1 - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} - engines: {node: '>=0.10.0'} + react@18.3.1: dependencies: loose-envify: 1.4.0 - dev: false - /read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + read-cache@1.0.0: dependencies: pify: 2.3.0 - dev: true - /readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + readdirp@3.6.0: dependencies: - picomatch: 2.3.1 - dev: true + picomatch: 2.3.2 - /redux@4.2.1: - resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + redux@4.2.1: dependencies: - '@babel/runtime': 7.28.6 - dev: false + '@babel/runtime': 7.29.2 - /reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 @@ -4002,11 +4858,8 @@ packages: get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 - dev: true - /regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 @@ -4014,40 +4867,20 @@ packages: get-proto: 1.0.1 gopd: 1.2.0 set-function-name: 2.0.2 - dev: true - /require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - dev: true + require-directory@2.1.1: {} - /requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - dev: true + resize-observer-polyfill@1.5.1: {} - /resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - dev: false + resolve-from@4.0.0: {} - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - dev: true - - /resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true + resolve@1.22.11: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: true - /resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} - engines: {node: '>= 0.4'} - hasBin: true + resolve@2.0.0-next.6: dependencies: es-errors: 1.3.0 is-core-module: 2.16.1 @@ -4055,116 +4888,82 @@ packages: object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: true - /reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true + rettime@0.10.1: {} - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true + reusify@1.1.0: {} + + rimraf@3.0.2: dependencies: glob: 7.2.3 - dev: true - /rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 - dev: true - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - dev: true - /safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 isarray: 2.0.5 - dev: true - /safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 isarray: 2.0.5 - dev: true - /safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} + safe-regex-test@1.1.0: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-regex: 1.2.1 - dev: true - /scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 - dev: false - /scroll-into-view-if-needed@3.1.0: - resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 - dev: false - /semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - dev: true + semver@6.3.1: {} - /semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - dev: true + semver@7.7.4: {} - /set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 es-errors: 1.3.0 @@ -4172,132 +4971,80 @@ packages: get-intrinsic: 1.3.0 gopd: 1.2.0 has-property-descriptors: 1.0.2 - dev: true - /set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} + set-function-name@2.0.2: dependencies: define-data-property: 1.1.4 es-errors: 1.3.0 functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 - dev: true - /set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} + set-proto@1.0.0: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 es-object-atoms: 1.1.1 - dev: true - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 - dev: true - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - dev: true + shebang-regex@3.0.0: {} - /side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - dev: true - /side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 get-intrinsic: 1.3.0 object-inspect: 1.13.4 - dev: true - /side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} + side-channel-weakmap@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1 - dev: true - /side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} + side-channel@1.1.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - dev: true - /signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - dev: true + signal-exit@4.1.0: {} - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: true + slash@3.0.0: {} - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - dev: true + source-map-js@1.2.1: {} - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - dev: true + source-map@0.6.1: {} - /statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - dev: true + statuses@2.0.2: {} - /stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 - dev: true - /strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - dev: true + strict-event-emitter@0.5.1: {} - /string-convert@0.2.1: - resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} - dev: false + string-convert@0.2.1: {} - /string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - dev: true - /string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -4312,11 +5059,13 @@ packages: regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 side-channel: 1.1.0 - dev: true - /string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 @@ -4325,47 +5074,29 @@ packages: es-abstract: 1.24.1 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 - dev: true - /string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} + string.prototype.trimend@1.0.9: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 - dev: true - /string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} + string.prototype.trimstart@1.0.8: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 es-object-atoms: 1.1.1 - dev: true - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - dev: true - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - dev: true + strip-json-comments@3.1.1: {} - /stylis@4.3.6: - resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} - dev: false + stylis@4.3.6: {} - /sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 @@ -4374,24 +5105,16 @@ packages: pirates: 4.0.7 tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 - dev: true - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 - dev: true - /supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - dev: true + supports-preserve-symlinks-flag@1.0.0: {} - /tailwindcss@3.3.6: - resolution: {integrity: sha512-AKjF7qbbLvLaPieoKeTjG1+FyNZT6KaJMJPFeQyLfIp7l82ggH1fbHJSsYIvnbTFQOlkh+gBYpyby5GT1LIdLw==} - engines: {node: '>=14.0.0'} - hasBin: true + tagged-tag@1.0.0: {} + + tailwindcss@3.4.19: dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -4402,131 +5125,89 @@ packages: glob-parent: 6.0.2 is-glob: 4.0.3 jiti: 1.21.7 - lilconfig: 2.1.0 + lilconfig: 3.1.3 micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.4.32 - postcss-import: 15.1.0(postcss@8.4.32) - postcss-js: 4.1.0(postcss@8.4.32) - postcss-load-config: 4.0.2(postcss@8.4.32) - postcss-nested: 6.2.0(postcss@8.4.32) + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8) + postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.2 resolve: 1.22.11 sucrase: 3.35.1 transitivePeerDependencies: - - ts-node - dev: true + - tsx + - yaml - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true + text-table@0.2.0: {} - /thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 - dev: true - /thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thenify@3.3.1: dependencies: any-promise: 1.3.0 - dev: true - /throttle-debounce@5.0.2: - resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} - engines: {node: '>=12.22'} - dev: false + throttle-debounce@5.0.2: {} - /tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - dev: false + tiny-invariant@1.3.3: {} - /tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - dev: true + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - dev: true - /toggle-selection@1.0.6: - resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} - dev: false + toggle-selection@1.0.6: {} - /tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} + tough-cookie@6.0.1: dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - dev: true + tldts: 7.0.27 - /ts-api-utils@1.4.3(typescript@5.2.2): - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' + ts-api-utils@1.4.3(typescript@5.9.3): dependencies: - typescript: 5.2.2 - dev: true + typescript: 5.9.3 - /ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - dev: true + ts-interface-checker@0.1.13: {} - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - dev: true - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - dev: true + type-fest@0.20.2: {} - /type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - dev: true + type-fest@5.5.0: + dependencies: + tagged-tag: 1.0.0 - /typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-typed-array: 1.1.15 - dev: true - /typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.3: dependencies: call-bind: 1.0.8 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 - dev: true - /typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.8 @@ -4535,11 +5216,8 @@ packages: has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - dev: true - /typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} + typed-array-length@1.0.7: dependencies: call-bind: 1.0.8 for-each: 0.3.5 @@ -4547,132 +5225,58 @@ packages: is-typed-array: 1.1.15 possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - dev: true - /typescript@5.2.2: - resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} - engines: {node: '>=14.17'} - hasBin: true - dev: true + typescript@5.9.3: {} - /uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - requiresBuild: true - dev: true + uglify-js@3.19.3: optional: true - /unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 has-bigints: 1.1.0 has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - dev: true - /universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - dev: true + universalify@2.0.1: {} - /universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - dev: true + until-async@3.0.2: {} - /update-browserslist-db@1.2.3(browserslist@4.28.1): - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 - dev: true - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - dev: true - /url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-memo-one@1.1.3(react@18.3.1): dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - dev: true + react: 18.3.1 - /use-memo-one@1.1.3(react@18.2.0): - resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: {} + + vite@5.4.21: dependencies: - react: 18.2.0 - dev: false - - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true - - /vite@5.0.8: - resolution: {integrity: sha512-jYMALd8aeqR3yS9xlHd0OzQJndS9fH5ylVgWdB+pxTwxLKdO1pgC5Dlb398BUxpfaBxa4M9oT7j1g503Gaj5IQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - esbuild: 0.19.12 - postcss: 8.4.32 - rollup: 4.59.0 + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.1 optionalDependencies: fsevents: 2.3.3 - dev: true - /void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - dev: false + void-elements@3.1.0: {} - /which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 is-boolean-object: 1.2.2 is-number-object: 1.1.1 is-string: 1.1.1 is-symbol: 1.1.1 - dev: true - /which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} + which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 function.prototype.name: 1.1.8 @@ -4687,21 +5291,15 @@ packages: which-boxed-primitive: 1.1.1 which-collection: 1.0.2 which-typed-array: 1.1.20 - dev: true - /which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} + which-collection@1.0.2: dependencies: is-map: 2.0.3 is-set: 2.0.3 is-weakmap: 2.0.2 is-weakset: 2.0.4 - dev: true - /which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.8 @@ -4710,70 +5308,36 @@ packages: get-proto: 1.0.1 gopd: 1.2.0 has-tostringtag: 1.0.2 - dev: true - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true + which@2.0.2: dependencies: isexe: 2.0.0 - dev: true - /word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - dev: true + word-wrap@1.2.5: {} - /wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - dev: true + wordwrap@1.0.0: {} - /wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true - /wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true + wrappy@1.0.2: {} - /y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - dev: true + y18n@5.0.8: {} - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: true + yallist@3.1.1: {} - /yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} - engines: {node: '>= 14.6'} - hasBin: true - dev: true + yargs-parser@21.1.1: {} - /yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - dev: true - - /yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + yargs@17.7.2: dependencies: cliui: 8.0.1 escalade: 3.2.0 @@ -4782,36 +5346,12 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 - dev: true - /yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - dev: true + yocto-queue@0.1.0: {} - /yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - dev: true + yoctocolors-cjs@2.1.3: {} - /zustand@5.0.11(@types/react@18.2.37)(react@18.2.0): - resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - dependencies: - '@types/react': 18.2.37 - react: 18.2.0 - dev: false + zustand@5.0.12(@types/react@18.3.28)(react@18.3.1): + optionalDependencies: + '@types/react': 18.3.28 + react: 18.3.1 diff --git a/front/public/env.js b/front/public/env.js index 8424a84..bf3f94f 100644 --- a/front/public/env.js +++ b/front/public/env.js @@ -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', +} diff --git a/front/src/pages/aiStudio/assets/utils.ts b/front/src/pages/aiStudio/assets/utils.ts index 38b7e8e..a7877a3 100644 --- a/front/src/pages/aiStudio/assets/utils.ts +++ b/front/src/pages/aiStudio/assets/utils.ts @@ -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`) } - diff --git a/front/src/services/http.ts b/front/src/services/http.ts index ebd563f..29e8c05 100644 --- a/front/src/services/http.ts +++ b/front/src/services/http.ts @@ -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 = (url: string, config?: AxiosRequestConfig): Prom http.delete(url, config) as Promise export default http - diff --git a/front/src/services/openapi.ts b/front/src/services/openapi.ts index 7a46b18..b27fffc 100644 --- a/front/src/services/openapi.ts +++ b/front/src/services/openapi.ts @@ -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) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bfd7470 --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/site/content/blog/backend-refactor-phase-1.md b/site/content/blog/backend-refactor-phase-1.md new file mode 100644 index 0000000..48b4e8c --- /dev/null +++ b/site/content/blog/backend-refactor-phase-1.md @@ -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. 第二轮工程化清理 + +- 继续拆剩余历史大文件 +- 收敛少量残留错误文案和命名差异 +- 补更多细粒度测试 + +如果按当前收益来看,下一步最值得优先切到: + +**交互性优化。** diff --git a/site/content/docs/guide/_index.md b/site/content/docs/guide/_index.md index 0212378..2b843df 100644 --- a/site/content/docs/guide/_index.md +++ b/site/content/docs/guide/_index.md @@ -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/) diff --git a/site/content/docs/guide/backend.md b/site/content/docs/guide/backend.md index cbc5097..3af18c7 100644 --- a/site/content/docs/guide/backend.md +++ b/site/content/docs/guide/backend.md @@ -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 层测试。