From 505462a830bb7bf078472ad8a0b7607e7fdb16e3 Mon Sep 17 00:00:00 2001 From: shiuyu Date: Fri, 24 Jul 2026 20:01:38 +0800 Subject: [PATCH 1/5] feat(cas): add EpisodePackage importer v1 with durable idempotency ledger --- MASTER_PLAN.md | 92 ++++ backend/app/api/v1/__init__.py | 7 + backend/app/core/db.py | 2 + backend/app/crypto_animal_studio/__init__.py | 17 + .../crypto_animal_studio/agents/__init__.py | 4 + .../app/crypto_animal_studio/api/__init__.py | 15 + .../app/crypto_animal_studio/api/health.py | 29 + .../api/import_episode.py | 51 ++ .../application/__init__.py | 5 + .../application/hashing.py | 36 ++ .../application/import_episode.py | 451 ++++++++++++++++ .../application/import_result.py | 47 ++ .../crypto_animal_studio/domain/__init__.py | 25 + .../domain/episode_package.py | 90 ++++ .../domain/import_ledger.py | 64 +++ .../crypto_animal_studio/domain/mapping.py | 120 +++++ .../integrations/__init__.py | 6 + .../crypto_animal_studio/schemas/__init__.py | 37 ++ .../schemas/episode_package.py | 351 +++++++++++++ .../schemas/import_request.py | 21 + .../schemas/import_result.py | 14 + backend/sql/009-add-cas-import-ledger.sql | 28 + .../tests/test_cas_episode_package_schema.py | 217 ++++++++ backend/tests/test_cas_health_api.py | 31 ++ backend/tests/test_cas_import_api.py | 91 ++++ backend/tests/test_cas_import_episode.py | 349 ++++++++++++ backend/tests/test_cas_import_unit.py | 81 +++ docs/adr/ADR-012-episode-importer.md | 257 +++++++++ docs/adr/ADR-013-cas-import-ledger.md | 159 ++++++ docs/architecture-analysis.md | 496 ++++++++++++++++++ .../episode-package-v1.md | 199 +++++++ docs/crypto-animal-studio/import-mapper-v1.md | 145 +++++ .../samples/sample-episode-package-v1.json | 169 ++++++ docs/implementation-log.md | 215 ++++++++ 34 files changed, 3921 insertions(+) create mode 100644 MASTER_PLAN.md create mode 100644 backend/app/crypto_animal_studio/__init__.py create mode 100644 backend/app/crypto_animal_studio/agents/__init__.py create mode 100644 backend/app/crypto_animal_studio/api/__init__.py create mode 100644 backend/app/crypto_animal_studio/api/health.py create mode 100644 backend/app/crypto_animal_studio/api/import_episode.py create mode 100644 backend/app/crypto_animal_studio/application/__init__.py create mode 100644 backend/app/crypto_animal_studio/application/hashing.py create mode 100644 backend/app/crypto_animal_studio/application/import_episode.py create mode 100644 backend/app/crypto_animal_studio/application/import_result.py create mode 100644 backend/app/crypto_animal_studio/domain/__init__.py create mode 100644 backend/app/crypto_animal_studio/domain/episode_package.py create mode 100644 backend/app/crypto_animal_studio/domain/import_ledger.py create mode 100644 backend/app/crypto_animal_studio/domain/mapping.py create mode 100644 backend/app/crypto_animal_studio/integrations/__init__.py create mode 100644 backend/app/crypto_animal_studio/schemas/__init__.py create mode 100644 backend/app/crypto_animal_studio/schemas/episode_package.py create mode 100644 backend/app/crypto_animal_studio/schemas/import_request.py create mode 100644 backend/app/crypto_animal_studio/schemas/import_result.py create mode 100644 backend/sql/009-add-cas-import-ledger.sql create mode 100644 backend/tests/test_cas_episode_package_schema.py create mode 100644 backend/tests/test_cas_health_api.py create mode 100644 backend/tests/test_cas_import_api.py create mode 100644 backend/tests/test_cas_import_episode.py create mode 100644 backend/tests/test_cas_import_unit.py create mode 100644 docs/adr/ADR-012-episode-importer.md create mode 100644 docs/adr/ADR-013-cas-import-ledger.md create mode 100644 docs/architecture-analysis.md create mode 100644 docs/crypto-animal-studio/episode-package-v1.md create mode 100644 docs/crypto-animal-studio/import-mapper-v1.md create mode 100644 docs/crypto-animal-studio/samples/sample-episode-package-v1.json create mode 100644 docs/implementation-log.md diff --git a/MASTER_PLAN.md b/MASTER_PLAN.md new file mode 100644 index 00000000..1b6bcd29 --- /dev/null +++ b/MASTER_PLAN.md @@ -0,0 +1,92 @@ +# MASTER PLAN — Crypto Animal Studio × Jellyfish + +Governance document for integrating **Crypto Animal Studio (CAS / Creative OS)** into +**Jellyfish** as the production platform. This file is the single high-level source of +truth for scope, approach, and the approved architectural decisions. Detailed current +architecture lives in `docs/architecture-analysis.md`; the contract lives in +`docs/crypto-animal-studio/episode-package-v1.md`. + +--- + +## 1. Goal + +Jellyfish is the **main project** (an end-to-end AI short-drama production workspace: +FastAPI + LangChain/LangGraph + SQLAlchemy, async task center, asset consistency, +image/video generation). CAS is the **upstream creative brain** that turns a news or +original premise into a fully-formed episode (script + storyboard + dialogue + character +and asset definitions). CAS delivers that episode to Jellyfish as a single validated +**EpisodePackage**, which Jellyfish then produces and exports. + +``` +CAS (creative brain) Jellyfish (production platform) +premise → episode + storyboard ──▶ EpisodePackage (contract) + + dialogue + characters + assets ──▶ Chapter + Shots → assets → generation → export +``` + +## 2. Approach + +Integrate CAS as a **bounded module** inside the Jellyfish backend +(`backend/app/crypto_animal_studio/`), reusing Jellyfish's existing Project / Chapter / +Shot / Asset / Media / Prompt / Task / Provider systems. Do **not** fork a parallel +platform. Build additively and keep every change inside the module plus a thin router +registration. + +## 3. Approved final decisions (authoritative) + +These are approved and binding. The ADR table with reasons/consequences is in +`docs/architecture-analysis.md` → "Final Architecture Decisions". + +1. **A CAS Episode maps to one Jellyfish Chapter.** +2. **A Jellyfish Project represents a series / production / season** and may contain + multiple CAS Chapters/Episodes. +3. **EpisodePackage directly creates Shots** (Chapter / Shot / ShotDetail / + ShotDialogLine + character & asset links) from the completed storyboard. +4. **Completed CAS storyboards must not be passed through `ScriptDividerAgent`** — + re-dividing would destroy comedy beats, timing, dialogue alignment, and shot structure. +5. **`Chapter.raw_text` stores the complete generated script for traceability**, but + Shots are created from the EpisodePackage, not by re-dividing `raw_text`. +6. **No duplicate systems**: do not create parallel CAS Project / Episode / Shot / Asset + / Media / Prompt / Task / Provider systems; reuse Jellyfish's. +7. **Provider configuration ultimately converges on Jellyfish** (`Provider` / `Model` / + `ModelSettings`). A temporary adapter is allowed during transition; a second + independent provider configuration system is not. +8. **The initial importer is synchronous** (no Celery for the first milestone). +9. **No new `ProjectStyle` or `ProjectVisualStyle` enum values** in the initial phase. + +## 4. Sprint roadmap + +| Sprint | Scope | Status | +|---|---|---| +| 2 — CAS Foundation | Bounded module + EpisodePackage v1 schema + validation + sample + docs + health endpoint + tests | Done | +| 2.1 — Foundation Hardening | Governance docs (this file + architecture-analysis) + structured `CameraSpec` (shot_type/angle/movement, CAS-local enums) + doc/sample/test/log updates | Done | +| 3 — Synchronous importer | EpisodePackage → validation → **synchronous** import service → create Chapter + Shots (+ ShotDetail/dialogue/links); no Celery/LLM/frontend | Planned | +| 4 — Consistency & generation hand-off | Seed characters/assets as Jellyfish entities; wire shots into existing generation workspace | Planned | +| 5 — Provider convergence & UX | Retire temporary provider adapter onto Jellyfish Provider/Model/ModelSettings; frontend entry; OpenAPI regen | Planned | + +## 5. Scope guardrails (until explicitly lifted) + +Do not, in the foundation phases: create DB tables, modify ORM models, create SQL +migrations, create Chapter/Shot records (before the importer sprint), add Celery tasks, +add Redis/S3 logic, invoke an LLM, migrate Creative OS agents, modify generated frontend +clients, build frontend UI, add a second provider system, or add +`ProjectStyle`/`ProjectVisualStyle` enum values. + +## 6. Module boundary + +``` +backend/app/crypto_animal_studio/ +├── api/ # thin routes → ApiResponse (health now; import later) +├── application/ # use cases (future synchronous import service) +├── domain/ # constants/enums/helpers (SCHEMA_VERSION, SourceType, camera enums) +├── schemas/ # the ONLY Pydantic models for EpisodePackage +├── agents/ # (future) CAS-specific agents +└── integrations/ # (future) bridge to Jellyfish Provider/Model/ModelSettings +``` + +## 7. References + +- `docs/architecture-analysis.md` — full current architecture + Final Architecture Decisions (ADR). +- `docs/crypto-animal-studio/episode-package-v1.md` — EpisodePackage v1 contract. +- `docs/crypto-animal-studio/samples/sample-episode-package-v1.json` — valid sample. +- `docs/implementation-log.md` — per-sprint implementation log. +- `AGENTS.md` / `.cursor/rules/*` — repository conventions and definition of done. diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index ccada218..21401f0a 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api.v1.routes import film, health, llm, studio, script_processing +from app.crypto_animal_studio.api import router as crypto_animal_studio_router router = APIRouter() @@ -11,3 +12,9 @@ router.include_router(llm.router, prefix="/llm", tags=["llm"]) router.include_router(studio.router, prefix="/studio") router.include_router(script_processing.router) +# Crypto Animal Studio(CAS)受限边界模块:通过既有聚合机制挂载,不新建独立 FastAPI app。 +router.include_router( + crypto_animal_studio_router, + prefix="/crypto-animal-studio", + tags=["crypto-animal-studio"], +) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 9fc769f3..d7fedd6b 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -66,6 +66,8 @@ async def init_db() -> None: import app.models.studio # noqa: F401 import app.models.task # noqa: F401 import app.models.task_links # noqa: F401 + # CAS 边界模块的导入台账表(幂等记账);导入以注册到 Base.metadata。 + import app.crypto_animal_studio.domain.import_ledger # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/crypto_animal_studio/__init__.py b/backend/app/crypto_animal_studio/__init__.py new file mode 100644 index 00000000..3e189f26 --- /dev/null +++ b/backend/app/crypto_animal_studio/__init__.py @@ -0,0 +1,17 @@ +"""Crypto Animal Studio (CAS) bounded module. + +用途: +- 作为 Creative OS(CAS) 与 Jellyfish 之间的**受限边界模块**(bounded context)。 +- 本 Sprint(Sprint 2 · CAS Foundation)只建立契约与最小健康端点, + **不做**数据库落地、Chapter/Shot 导入、Celery、LLM 调用或前端 UI。 + +分层职责: +- ``schemas``:对外传输 / 校验用的 Pydantic 模型(EpisodePackage v1)。 +- ``domain``:常量、枚举、领域辅助函数(不依赖 FastAPI,不重复定义 schemas 模型)。 +- ``api``:轻量路由层(薄),仅收参并返回 Jellyfish 统一的 ``ApiResponse`` 壳。 +- ``application`` / ``agents`` / ``integrations``:占位分层,后续 Sprint 逐步落地。 +""" + +from app.crypto_animal_studio.domain.episode_package import SCHEMA_VERSION + +__all__ = ["SCHEMA_VERSION"] diff --git a/backend/app/crypto_animal_studio/agents/__init__.py b/backend/app/crypto_animal_studio/agents/__init__.py new file mode 100644 index 00000000..9a8236e9 --- /dev/null +++ b/backend/app/crypto_animal_studio/agents/__init__.py @@ -0,0 +1,4 @@ +"""CAS agents 层(占位)。 + +预留 CAS 专属 agent 的落点(如需要)。Sprint 2 不迁移 Creative OS agent、不调用 LLM。 +""" diff --git a/backend/app/crypto_animal_studio/api/__init__.py b/backend/app/crypto_animal_studio/api/__init__.py new file mode 100644 index 00000000..6ffd4ac3 --- /dev/null +++ b/backend/app/crypto_animal_studio/api/__init__.py @@ -0,0 +1,15 @@ +"""CAS api 层:轻量路由聚合。 + +职责:仅收参、组织响应(统一 ``ApiResponse`` 壳);不承载业务逻辑。 +本聚合 router 由 ``app.api.v1`` 以 ``/crypto-animal-studio`` 前缀挂载。 +""" + +from fastapi import APIRouter + +from app.crypto_animal_studio.api import health, import_episode + +router = APIRouter() +router.include_router(health.router) +router.include_router(import_episode.router) + +__all__ = ["router"] diff --git a/backend/app/crypto_animal_studio/api/health.py b/backend/app/crypto_animal_studio/api/health.py new file mode 100644 index 00000000..a36b7eda --- /dev/null +++ b/backend/app/crypto_animal_studio/api/health.py @@ -0,0 +1,29 @@ +"""CAS 健康检查端点(v1 内,薄路由)。 + +用途:提供一个不依赖数据库/外部服务的轻量端点,用于确认 CAS 模块已正确注册, +并对外暴露当前 EpisodePackage 契约版本,便于集成方做版本探测。 +""" + +from fastapi import APIRouter + +from app.crypto_animal_studio.domain.episode_package import SCHEMA_VERSION +from app.schemas.common import ApiResponse, success_response + +router = APIRouter() + + +@router.get("/health", response_model=ApiResponse[dict]) +async def cas_health() -> ApiResponse[dict]: + """返回 CAS 模块健康状态与契约版本。 + + 返回: + 统一 ``ApiResponse`` 壳,data 形如 + ``{"service": "crypto-animal-studio", "status": "ok", "schema_version": "1.0"}``。 + """ + return success_response( + data={ + "service": "crypto-animal-studio", + "status": "ok", + "schema_version": SCHEMA_VERSION, + } + ) diff --git a/backend/app/crypto_animal_studio/api/import_episode.py b/backend/app/crypto_animal_studio/api/import_episode.py new file mode 100644 index 00000000..77b3eae8 --- /dev/null +++ b/backend/app/crypto_animal_studio/api/import_episode.py @@ -0,0 +1,51 @@ +"""CAS EpisodePackage 导入端点(薄路由)。 + +职责仅限:收参、依赖注入、调用 application 层导入服务、把领域异常翻译为 HTTP、 +用统一 ``ApiResponse`` 壳返回。业务逻辑全在 application 层。 + +事务:复用 ``get_db`` 请求级会话(单事务、成功提交一次、异常回滚)。 +导入服务只 flush;dry-run 在服务内部 rollback,故不写库。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.application.import_episode import ( + EpisodeAlreadyImportedError, + IdempotencyConflictError, + ProjectNotFoundError, + import_episode, +) +from app.crypto_animal_studio.application.import_result import ImportResult +from app.crypto_animal_studio.schemas.import_request import ImportEpisodeRequest +from app.dependencies import get_db +from app.schemas.common import ApiResponse, success_response + +router = APIRouter() + + +@router.post("/import", response_model=ApiResponse[ImportResult]) +async def import_episode_endpoint( + body: ImportEpisodeRequest, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[ImportResult]: + """导入一个 EpisodePackage 为一个 Jellyfish Chapter(含 Shots 等)。 + + 返回:统一 ``ApiResponse``,data 为 ImportResult。 + 错误:项目不存在→404;幂等冲突/重复导入→409;契约校验失败→422(由 pydantic)。 + """ + try: + result = await import_episode( + db, + project_id=body.project_id, + package=body.episode_package, + idempotency_key=body.idempotency_key, + dry_run=body.dry_run, + ) + except ProjectNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except (IdempotencyConflictError, EpisodeAlreadyImportedError) as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + return success_response(data=result) diff --git a/backend/app/crypto_animal_studio/application/__init__.py b/backend/app/crypto_animal_studio/application/__init__.py new file mode 100644 index 00000000..7eabb995 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/__init__.py @@ -0,0 +1,5 @@ +"""CAS application 层(占位)。 + +后续 Sprint 在此放置用例编排,例如「EpisodePackage → 校验 → 同步导入 service」。 +Sprint 2 仅建立分层骨架,暂无实现(不含数据库落地、不含 Celery)。 +""" diff --git a/backend/app/crypto_animal_studio/application/hashing.py b/backend/app/crypto_animal_studio/application/hashing.py new file mode 100644 index 00000000..1a156cdc --- /dev/null +++ b/backend/app/crypto_animal_studio/application/hashing.py @@ -0,0 +1,36 @@ +"""EpisodePackage 规范化哈希(canonical payload hash)。 + +用途:为幂等提供**确定性**指纹。对已校验的 EpisodePackage 以稳定键序序列化后取 +SHA-256。相同语义的 payload 总是得到相同哈希;任何字段变化都会改变哈希。 +""" + +from __future__ import annotations + +import hashlib +import json + +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage + + +def canonical_payload_hash(package: EpisodePackage) -> str: + """返回 EpisodePackage 的规范化 SHA-256 十六进制摘要。 + + 步骤: + 1. ``model_dump(mode="json")`` 得到可 JSON 序列化的纯数据(枚举转字符串等)。 + 2. ``json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)`` + —— 稳定键序、无多余空白、保留 Unicode,保证确定性。 + 3. 对 UTF-8 字节做 SHA-256。 + + 参数: + package: 已通过校验的 EpisodePackage。 + 返回: + 64 位十六进制 SHA-256 字符串。 + """ + payload = package.model_dump(mode="json") + serialized = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +__all__ = ["canonical_payload_hash"] diff --git a/backend/app/crypto_animal_studio/application/import_episode.py b/backend/app/crypto_animal_studio/application/import_episode.py new file mode 100644 index 00000000..58caacaa --- /dev/null +++ b/backend/app/crypto_animal_studio/application/import_episode.py @@ -0,0 +1,451 @@ +"""EpisodePackage 导入服务(application 层)。 + +职责严格限定为:**Validate → Map → Reuse → Create → Rollback**。绝不调用 LLM、 +ScriptDivider、ElementExtractor、Celery、Redis、providers,也不生成资产、不改写台词/提示词。 + +事务模型: +- 复用 Jellyfish 的请求级会话(``get_db``):会话在请求成功时 commit 一次、异常时 rollback。 +- 本服务只 ``flush``(经 ``create_and_refresh``),从不自行 commit;因此整个导入天然是 + **恰好一个事务、提交一次**。 +- dry-run:在构建并 flush 校验后调用 ``rollback()``,从而不写库(``get_db`` 随后的 commit 变为空提交)。 + +复用说明:Jellyfish 未提供「章节/整包导入」聚合 service;studio 各 service 本质是 +``db.add + create_and_refresh(flush, 不 commit)`` 的薄封装。为保证「单事务」语义并避免重复 +业务逻辑,本导入器在同一请求会话上复用 ``services.common.create_and_refresh`` 这一共享写原语, +按需直接构造既有 Jellyfish ORM 实体(不新建任何平行系统)。 +""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.application.hashing import canonical_payload_hash +from app.crypto_animal_studio.application.import_result import ImportCounts, ImportResult +from app.crypto_animal_studio.domain import mapping +from app.crypto_animal_studio.domain.import_ledger import CasImportLedger +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage +from app.models.studio import ( + Chapter, + Character, + Actor, + Costume, + Prop, + Project, + ProjectCostumeLink, + ProjectPropLink, + ProjectSceneLink, + Scene, + Shot, + ShotCharacterLink, + ShotDetail, + ShotDialogLine, +) +from app.models.types import ChapterStatus, ShotStatus +from app.services.common import create_and_refresh + + +# --------------------------------------------------------------------------- # +# 领域异常(application 层,不依赖 FastAPI;由 api 层翻译为 HTTP) +# --------------------------------------------------------------------------- # +class CasImportError(Exception): + """CAS 导入错误基类。""" + + +class ProjectNotFoundError(CasImportError): + """目标 Project 不存在。""" + + +class IdempotencyConflictError(CasImportError): + """同一 (project, idempotency_key) 下 payload 发生变化。""" + + +class EpisodeAlreadyImportedError(CasImportError): + """同一 (project, episode) 已在另一幂等键下导入。""" + + +class _EntityResolver: + """事务内的资产/角色解析器:复用优先、必要才新建、单事务内不重复。""" + + def __init__(self, db: AsyncSession, project: Project) -> None: + """记录会话与目标项目,并初始化缓存与计数。""" + self._db = db + self._project = project + self._cache: dict[tuple[str, str], str] = {} + self.created = ImportCounts() + self.reused = ImportCounts() + + async def _resolve_global_asset(self, kind: str, model: type, name: str, description: str) -> str: + """解析全局资产(Actor/Scene/Prop/Costume):按规范化名称复用,否则新建。""" + norm = mapping.normalize_key(name) + cache_key = (kind, norm) + if cache_key in self._cache: + return self._cache[cache_key] + stmt = select(model).where(func.lower(model.name) == norm).limit(1) + existing = (await self._db.execute(stmt)).scalars().first() + if existing is not None: + self._cache[cache_key] = existing.id + setattr(self.reused, kind, getattr(self.reused, kind) + 1) + return existing.id + obj = model( + id=str(uuid.uuid4()), + name=name, + description=description, + style=self._project.style, + visual_style=self._project.visual_style, + ) + await create_and_refresh(self._db, obj) + self._cache[cache_key] = obj.id + setattr(self.created, kind, getattr(self.created, kind) + 1) + return obj.id + + async def resolve_actor(self, name: str, description: str) -> str: + """解析/新建 Actor(视觉演员)。""" + return await self._resolve_global_asset("actors", Actor, name, description) + + async def resolve_scene(self, name: str, description: str) -> str: + """解析/新建 Scene。""" + return await self._resolve_global_asset("scenes", Scene, name, description) + + async def resolve_prop(self, name: str, description: str) -> str: + """解析/新建 Prop。""" + return await self._resolve_global_asset("props", Prop, name, description) + + async def resolve_costume(self, name: str, description: str) -> str: + """解析/新建 Costume。""" + return await self._resolve_global_asset("costumes", Costume, name, description) + + async def resolve_character( + self, name: str, description: str, actor_id: str | None, costume_id: str | None + ) -> str: + """解析/新建 Character(项目内、按名称复用)。Character≠Actor,绝不合并。""" + norm = mapping.normalize_key(name) + cache_key = ("characters", norm) + if cache_key in self._cache: + return self._cache[cache_key] + stmt = ( + select(Character) + .where(Character.project_id == self._project.id, func.lower(Character.name) == norm) + .limit(1) + ) + existing = (await self._db.execute(stmt)).scalars().first() + if existing is not None: + self._cache[cache_key] = existing.id + self.reused.characters += 1 + return existing.id + obj = Character( + id=str(uuid.uuid4()), + project_id=self._project.id, + name=name, + description=description, + style=self._project.style, + visual_style=self._project.visual_style, + actor_id=actor_id, + costume_id=costume_id, + ) + await create_and_refresh(self._db, obj) + self._cache[cache_key] = obj.id + self.created.characters += 1 + return obj.id + + +async def import_episode( + db: AsyncSession, + *, + project_id: str, + package: EpisodePackage, + idempotency_key: str, + dry_run: bool = False, +) -> ImportResult: + """把一个已校验的 EpisodePackage 导入为一个 Jellyfish Chapter(含 Shots 等)。 + + 参数: + db: 请求级异步会话(单事务;本函数只 flush,不自行 commit)。 + project_id: 目标项目(代表系列/季)。 + package: 已通过契约校验的 EpisodePackage。 + idempotency_key: 幂等键。 + dry_run: 为真时执行校验/映射/复用查找/告警但不写库。 + 返回: + ImportResult 摘要。 + 异常: + ProjectNotFoundError / IdempotencyConflictError / EpisodeAlreadyImportedError。 + """ + payload_hash = canonical_payload_hash(package) + + project = await db.get(Project, project_id) + if project is None: + raise ProjectNotFoundError(f"Project not found: {project_id}") + + # --- 幂等:先查台账(读,不写) --- + ledger_row = ( + await db.execute( + select(CasImportLedger).where( + CasImportLedger.project_id == project_id, + CasImportLedger.idempotency_key == idempotency_key, + ) + ) + ).scalars().first() + if ledger_row is not None: + if ledger_row.payload_hash == payload_hash: + # 同 key 同 payload → 幂等重放,返回既有结果,不重复建章节。 + return ImportResult( + status="replayed", + dry_run=dry_run, + idempotent_replay=True, + project_id=project_id, + episode_id=package.episode_id, + idempotency_key=idempotency_key, + payload_hash=payload_hash, + chapter_id=ledger_row.chapter_id, + chapter_index=None, + warnings=["idempotent replay: returned existing import result"], + ) + # 同 key 不同 payload → 冲突。 + raise IdempotencyConflictError( + f"idempotency_key '{idempotency_key}' already used with a different payload" + ) + + # 同 project+episode 已在另一 key 下导入 → 保守拒绝。 + dup_episode = ( + await db.execute( + select(CasImportLedger).where( + CasImportLedger.project_id == project_id, + CasImportLedger.episode_id == package.episode_id, + ) + ) + ).scalars().first() + if dup_episode is not None: + raise EpisodeAlreadyImportedError( + f"episode '{package.episode_id}' already imported under another idempotency_key" + ) + + warnings: list[str] = [] + resolver = _EntityResolver(db, project) + + # --- 章节序号(项目内递增) --- + max_index = ( + await db.execute(select(func.max(Chapter.index)).where(Chapter.project_id == project_id)) + ).scalar() + next_index = int(max_index or 0) + 1 + + # --- Chapter --- + chapter = Chapter( + id=str(uuid.uuid4()), + project_id=project_id, + index=next_index, + title=package.title, + summary=package.logline, + raw_text=mapping.assemble_raw_text(package), # 完整剧本,仅供追溯 + condensed_text="", + storyboard_count=len(package.shots), + status=ChapterStatus.draft, + ) + await create_and_refresh(db, chapter) + resolver.created.chapters += 1 + + # --- 角色(含 Actor/Costume 解析):Character≠Actor --- + actors_by_key = {a.actor_key: a for a in package.assets.actors} + scenes_by_key = {s.scene_key: s for s in package.assets.scenes} + props_by_key = {p.prop_key: p for p in package.assets.props} + costumes_by_key = {c.costume_key: c for c in package.assets.costumes} + + char_key_to_id: dict[str, str] = {} + for character in package.characters: + actor_id: str | None = None + if character.actor_key is not None: + spec = actors_by_key.get(character.actor_key) + actor_id = await resolver.resolve_actor( + (spec.display_name if spec and spec.display_name else character.actor_key), + (spec.description if spec else ""), + ) + else: + warnings.append( + f"character '{character.character_key}' has no actor_key; actor_id left unset" + ) + costume_id: str | None = None + if character.costume_key is not None: + spec = costumes_by_key.get(character.costume_key) + costume_id = await resolver.resolve_costume( + (spec.display_name if spec and spec.display_name else character.costume_key), + (spec.description if spec else ""), + ) + char_key_to_id[character.character_key] = await resolver.resolve_character( + character.display_name, character.description, actor_id, costume_id + ) + + # --- 逐镜头 --- + for shot_spec in sorted(package.shots, key=lambda s: s.sequence): + shot = Shot( + id=str(uuid.uuid4()), + chapter_id=chapter.id, + index=shot_spec.sequence, + title=shot_spec.title, + status=ShotStatus.pending, + skip_extraction=True, # CAS storyboard 权威:跳过抽取 + script_excerpt=shot_spec.script_excerpt, + ) + await create_and_refresh(db, shot) + resolver.created.shots += 1 + + camera_shot, angle, movement, cam_warnings = mapping.resolve_camera(shot_spec.camera) + warnings.extend(f"shot '{shot_spec.shot_id}': {w}" for w in cam_warnings) + + scene_id: str | None = None + if shot_spec.scene_key is not None: + spec = scenes_by_key.get(shot_spec.scene_key) + scene_id = await resolver.resolve_scene( + (spec.display_name if spec and spec.display_name else shot_spec.scene_key), + (spec.description if spec else ""), + ) + + if shot_spec.video_prompt: + warnings.append( + f"shot '{shot_spec.shot_id}': video_prompt not mapped (no ShotDetail field)" + ) + if shot_spec.negative_prompt: + warnings.append( + f"shot '{shot_spec.shot_id}': negative_prompt not mapped (no ShotDetail field)" + ) + + detail = ShotDetail( + id=shot.id, # 与 Shot 共享主键(1:1) + camera_shot=camera_shot, + angle=angle, + movement=movement, + scene_id=scene_id, + duration=mapping.round_duration(shot_spec.duration_seconds), + description=shot_spec.action, + first_frame_prompt="", + last_frame_prompt="", + key_frame_prompt=shot_spec.image_prompt, + ) + await create_and_refresh(db, detail) + resolver.created.shot_details += 1 + + # 对白 + for line in sorted(shot_spec.dialogue, key=lambda d: d.order): + line_mode, lm_warning = mapping.resolve_line_mode(line.line_mode) + if lm_warning: + warnings.append(f"shot '{shot_spec.shot_id}': {lm_warning}") + dialog = ShotDialogLine( + shot_detail_id=detail.id, + index=line.order, + text=line.text, + line_mode=line_mode, + speaker_character_id=char_key_to_id.get(line.character_key) + if line.character_key + else None, + speaker_name=mapping.dialogue_speaker_name(package, line), + ) + await create_and_refresh(db, dialog) + resolver.created.dialog_lines += 1 + + # 出场角色 → ShotCharacterLink + for order, ckey in enumerate(shot_spec.character_keys, start=1): + link = ShotCharacterLink( + shot_id=shot.id, character_id=char_key_to_id[ckey], index=order + ) + await create_and_refresh(db, link) + resolver.created.links += 1 + + # 场景 → ProjectSceneLink(shot 维度) + if scene_id is not None: + await create_and_refresh( + db, + ProjectSceneLink( + project_id=project_id, chapter_id=chapter.id, shot_id=shot.id, scene_id=scene_id + ), + ) + resolver.created.links += 1 + + # 道具 → ProjectPropLink + for pkey in shot_spec.prop_keys: + spec = props_by_key.get(pkey) + prop_id = await resolver.resolve_prop( + (spec.display_name if spec and spec.display_name else pkey), + (spec.description if spec else ""), + ) + await create_and_refresh( + db, + ProjectPropLink( + project_id=project_id, chapter_id=chapter.id, shot_id=shot.id, prop_id=prop_id + ), + ) + resolver.created.links += 1 + + # 服装 → ProjectCostumeLink + for kkey in shot_spec.costume_keys: + spec = costumes_by_key.get(kkey) + costume_id = await resolver.resolve_costume( + (spec.display_name if spec and spec.display_name else kkey), + (spec.description if spec else ""), + ) + await create_and_refresh( + db, + ProjectCostumeLink( + project_id=project_id, + chapter_id=chapter.id, + shot_id=shot.id, + costume_id=costume_id, + ), + ) + resolver.created.links += 1 + + if dry_run: + # 校验/映射/复用查找/告警均已完成;回滚以确保不写库。 + await db.rollback() + return ImportResult( + status="dry_run", + dry_run=True, + idempotent_replay=False, + project_id=project_id, + episode_id=package.episode_id, + idempotency_key=idempotency_key, + payload_hash=payload_hash, + chapter_id=None, + chapter_index=next_index, + created=resolver.created, + reused=resolver.reused, + warnings=warnings, + ) + + # 写入幂等台账(同事务)。唯一约束在并发下兜底为冲突。 + await create_and_refresh( + db, + CasImportLedger( + id=str(uuid.uuid4()), + project_id=project_id, + episode_id=package.episode_id, + idempotency_key=idempotency_key, + payload_hash=payload_hash, + chapter_id=chapter.id, + status="imported", + schema_version=package.schema_version, + ), + ) + + return ImportResult( + status="imported", + dry_run=False, + idempotent_replay=False, + project_id=project_id, + episode_id=package.episode_id, + idempotency_key=idempotency_key, + payload_hash=payload_hash, + chapter_id=chapter.id, + chapter_index=next_index, + created=resolver.created, + reused=resolver.reused, + warnings=warnings, + ) + + +__all__ = [ + "import_episode", + "CasImportError", + "ProjectNotFoundError", + "IdempotencyConflictError", + "EpisodeAlreadyImportedError", +] diff --git a/backend/app/crypto_animal_studio/application/import_result.py b/backend/app/crypto_animal_studio/application/import_result.py new file mode 100644 index 00000000..7c130825 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/import_result.py @@ -0,0 +1,47 @@ +"""导入结果模型(application 层)。 + +作为导入服务的返回值,也直接用于 API 响应的 data 部分。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ImportCounts(BaseModel): + """各类实体的计数(created 或 reused 各一份)。""" + + model_config = ConfigDict(extra="forbid") + + chapters: int = 0 + shots: int = 0 + shot_details: int = 0 + dialog_lines: int = 0 + characters: int = 0 + actors: int = 0 + scenes: int = 0 + props: int = 0 + costumes: int = 0 + links: int = 0 + + +class ImportResult(BaseModel): + """一次导入(或 dry-run / 重放)的结果摘要。""" + + model_config = ConfigDict(extra="forbid") + + status: str = Field(..., description="imported | dry_run | replayed") + dry_run: bool = Field(..., description="是否为 dry-run(未写库)") + idempotent_replay: bool = Field(..., description="是否命中幂等重放(返回既有结果)") + project_id: str + episode_id: str + idempotency_key: str + payload_hash: str = Field(..., description="EpisodePackage 规范化 SHA-256") + chapter_id: str | None = Field(None, description="产生/既有的 Chapter ID;dry-run 为 null") + chapter_index: int | None = Field(None, description="Chapter 在项目内的序号;dry-run 为拟用序号") + created: ImportCounts = Field(default_factory=ImportCounts, description="本次新建计数") + reused: ImportCounts = Field(default_factory=ImportCounts, description="本次复用计数") + warnings: list[str] = Field(default_factory=list, description="非阻断告警(不丢弃数据)") + + +__all__ = ["ImportResult", "ImportCounts"] diff --git a/backend/app/crypto_animal_studio/domain/__init__.py b/backend/app/crypto_animal_studio/domain/__init__.py new file mode 100644 index 00000000..072c52a3 --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/__init__.py @@ -0,0 +1,25 @@ +"""CAS 领域层(domain)。 + +职责:只存放常量、枚举、领域辅助函数;不定义传输/校验 Pydantic 模型 +(那些统一放在 ``crypto_animal_studio.schemas``),也不依赖 FastAPI。 +""" + +from app.crypto_animal_studio.domain.episode_package import ( + RECURRING_CHARACTER_KEYS, + SCHEMA_VERSION, + SUPPORTED_SOURCE_TYPES, + CasCameraAngle, + CasCameraMovement, + CasShotType, + is_supported_schema_version, +) + +__all__ = [ + "SCHEMA_VERSION", + "SUPPORTED_SOURCE_TYPES", + "RECURRING_CHARACTER_KEYS", + "CasShotType", + "CasCameraAngle", + "CasCameraMovement", + "is_supported_schema_version", +] diff --git a/backend/app/crypto_animal_studio/domain/episode_package.py b/backend/app/crypto_animal_studio/domain/episode_package.py new file mode 100644 index 00000000..90f1c3d8 --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/episode_package.py @@ -0,0 +1,90 @@ +"""EpisodePackage 领域常量与辅助(domain 层)。 + +用途: +- 集中管理与 EpisodePackage 契约相关的**常量 / 枚举 / 纯函数**,供 schemas 层复用, + 避免把「版本号」「合法来源类型」等散落在多处。 +- 本模块**不定义** Pydantic 传输模型(那些在 ``crypto_animal_studio.schemas``), + 也**不依赖 FastAPI**,以保持领域层可独立测试与复用。 +""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +# EpisodePackage 契约版本。Sprint 2 固定为 "1.0";升级规则见 docs/episode-package-v1.md。 +SCHEMA_VERSION: str = "1.0" + +# 新闻/素材来源类型的合法取值。schemas 层以 Literal 复用该集合的语义。 +SourceType = Literal["news", "original", "fictional", "generic"] + + +# --------------------------------------------------------------------------- # +# 相机(镜头)传输枚举 —— CAS 本地定义 +# +# 说明:这些取值**刻意与 Jellyfish 的 CameraShotType / CameraAngle / CameraMovement +# 存储 code 一一对齐**(存英文 code),以便后续导入器把 EpisodePackage 的 camera 干净地 +# 映射到 Jellyfish ShotDetail(camera_shot / angle / movement)。 +# 但按边界约定,**不**从 ORM 或 Jellyfish 数据库枚举导入——在 CAS 边界模块内独立声明, +# 避免 schemas 层反向依赖 app.models。 +# --------------------------------------------------------------------------- # +class CasShotType(str, Enum): + """景别(对齐 Jellyfish CameraShotType 的 code)。""" + + ECU = "ECU" + CU = "CU" + MCU = "MCU" + MS = "MS" + MLS = "MLS" + LS = "LS" + ELS = "ELS" + + +class CasCameraAngle(str, Enum): + """机位角度(对齐 Jellyfish CameraAngle 的 code)。""" + + EYE_LEVEL = "EYE_LEVEL" + HIGH_ANGLE = "HIGH_ANGLE" + LOW_ANGLE = "LOW_ANGLE" + BIRD_EYE = "BIRD_EYE" + DUTCH = "DUTCH" + OVER_SHOULDER = "OVER_SHOULDER" + + +class CasCameraMovement(str, Enum): + """运镜方式(对齐 Jellyfish CameraMovement 的 code)。""" + + STATIC = "STATIC" + PAN = "PAN" + TILT = "TILT" + DOLLY_IN = "DOLLY_IN" + DOLLY_OUT = "DOLLY_OUT" + TRACK = "TRACK" + CRANE = "CRANE" + HANDHELD = "HANDHELD" + STEADICAM = "STEADICAM" + ZOOM_IN = "ZOOM_IN" + ZOOM_OUT = "ZOOM_OUT" + +# 合法来源类型集合(供文档/校验/诊断复用;与 SourceType 保持一致)。 +SUPPORTED_SOURCE_TYPES: frozenset[str] = frozenset( + {"news", "original", "fictional", "generic"} +) + +# Crypto Animal Studio 常驻角色 key(领域参考,不做强制校验:单集不必六位全到场)。 +RECURRING_CHARACTER_KEYS: frozenset[str] = frozenset( + {"bull", "bear", "fox", "hammy", "monkey", "walter"} +) + + +def is_supported_schema_version(version: str) -> bool: + """判断给定 schema_version 是否被当前实现支持。 + + 参数: + version: 待检查的版本字符串。 + 返回: + 当且仅当 version 等于当前 ``SCHEMA_VERSION`` 时返回 True。 + 存在意义: + 让 schemas 校验与未来的多版本兼容判断共用同一处版本真相。 + """ + return version == SCHEMA_VERSION diff --git a/backend/app/crypto_animal_studio/domain/import_ledger.py b/backend/app/crypto_animal_studio/domain/import_ledger.py new file mode 100644 index 00000000..d163c19a --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/import_ledger.py @@ -0,0 +1,64 @@ +"""CAS 导入台账(import ledger)ORM 模型。 + +用途:为 EpisodePackage 导入提供**持久化幂等**支撑。每次成功导入写入一行,记录 +``(project_id, episode_id, idempotency_key, payload_hash, chapter_id)``,用于: +- 同 project + idempotency_key + 相同 payload_hash → 视为重放,返回既有 chapter,不重复建章节; +- 同 project + idempotency_key + 不同 payload_hash → 冲突失败; +- 同 project + episode_id 已在另一 key 下导入 → 保守拒绝。 + +边界说明:这是 CAS 边界模块自有的**记账表**,不是平行的 Project/Shot/Asset 业务系统; +它复用 Jellyfish 的 ``Base`` 与既有 ``projects`` / ``chapters`` 表,不复制任何业务实体。 +表结构由 `backend/sql/009-add-cas-import-ledger.sql` 迁移创建(经用户批准)。 +""" + +from __future__ import annotations + +from sqlalchemy import ForeignKey, Index, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base +from app.models.base import TimestampMixin + + +class CasImportLedger(Base, TimestampMixin): + """一次 EpisodePackage 导入的持久化记账行。""" + + __tablename__ = "cas_import_ledger" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="台账行 ID(UUID)") + project_id: Mapped[str] = mapped_column( + String(64), + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="所属项目 ID", + ) + episode_id: Mapped[str] = mapped_column(String(255), nullable=False, comment="CAS Episode ID") + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False, comment="幂等键") + payload_hash: Mapped[str] = mapped_column( + String(64), nullable=False, comment="EpisodePackage 规范化序列化的 SHA-256 十六进制" + ) + chapter_id: Mapped[str | None] = mapped_column( + String(64), + ForeignKey("chapters.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="导入产生的 Chapter ID(章节被删除时置空)", + ) + status: Mapped[str] = mapped_column( + String(32), nullable=False, default="imported", comment="导入状态(imported)" + ) + schema_version: Mapped[str] = mapped_column( + String(16), nullable=False, default="", comment="EpisodePackage 契约版本" + ) + + __table_args__ = ( + # 同一项目内,一个幂等键唯一 → 支撑重放/冲突判定 + UniqueConstraint("project_id", "idempotency_key", name="uq_cas_import_project_key"), + # 同一项目内,一个 episode 只导入一次 → 保守拒绝跨 key 重复导入 + UniqueConstraint("project_id", "episode_id", name="uq_cas_import_project_episode"), + Index("ix_cas_import_payload_hash", "payload_hash"), + ) + + +__all__ = ["CasImportLedger"] diff --git a/backend/app/crypto_animal_studio/domain/mapping.py b/backend/app/crypto_animal_studio/domain/mapping.py new file mode 100644 index 00000000..d31152c1 --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/mapping.py @@ -0,0 +1,120 @@ +"""EpisodePackage → Jellyfish 的**纯映射辅助**(domain 层)。 + +只含确定性、无副作用的纯函数:键规范化、相机默认值解析、对白模式校验、raw_text 组装、 +时长取整。不依赖 FastAPI、不依赖 ORM、不触库,便于独立单测。 +""" + +from __future__ import annotations + +from app.crypto_animal_studio.schemas.episode_package import ( + CameraSpec, + DialogueLine, + EpisodePackage, +) + +# 相机缺省值(EpisodePackage camera 为可选;Jellyfish ShotDetail 三字段为 NOT NULL)。 +DEFAULT_SHOT_TYPE = "MS" +DEFAULT_CAMERA_ANGLE = "EYE_LEVEL" +DEFAULT_CAMERA_MOVEMENT = "STATIC" + +# 合法对白模式(CAS 本地常量,对齐 Jellyfish DialogueLineMode 的 code)。 +VALID_LINE_MODES = frozenset({"DIALOGUE", "VOICE_OVER", "OFF_SCREEN", "PHONE"}) + + +def normalize_key(value: str) -> str: + """资产键/名称规范化:trim + lowercase,得到稳定比较键。 + + 参数: + value: 原始名称/键。 + 返回: + 去除首尾空白并转小写后的字符串(用于重用查找与去重)。 + """ + return (value or "").strip().lower() + + +def resolve_camera(camera: CameraSpec | None) -> tuple[str, str, str, list[str]]: + """把可选的 CameraSpec 解析为 Jellyfish ShotDetail 的三个 NOT NULL 相机字段。 + + 缺省字段用中性默认值填充,并对每个被默认的字段产生一条 warning(绝不静默丢弃)。 + + 返回: + ``(camera_shot, angle, movement, warnings)``,均为 code 字符串。 + """ + warnings: list[str] = [] + shot_type = DEFAULT_SHOT_TYPE + angle = DEFAULT_CAMERA_ANGLE + movement = DEFAULT_CAMERA_MOVEMENT + + if camera is None: + warnings.append("camera missing; defaulted shot_type/angle/movement") + return shot_type, angle, movement, warnings + + if camera.shot_type is not None: + shot_type = camera.shot_type.value + else: + warnings.append(f"camera.shot_type missing; defaulted to {DEFAULT_SHOT_TYPE}") + if camera.angle is not None: + angle = camera.angle.value + else: + warnings.append(f"camera.angle missing; defaulted to {DEFAULT_CAMERA_ANGLE}") + if camera.movement is not None: + movement = camera.movement.value + else: + warnings.append(f"camera.movement missing; defaulted to {DEFAULT_CAMERA_MOVEMENT}") + return shot_type, angle, movement, warnings + + +def resolve_line_mode(mode: str) -> tuple[str, str | None]: + """校验并规范化对白模式,非法值退回 DIALOGUE 并给出 warning。 + + 返回: + ``(code, warning_or_none)``。 + """ + code = (mode or "DIALOGUE").strip().upper() + if code in VALID_LINE_MODES: + return code, None + return "DIALOGUE", f"unknown dialogue line_mode '{mode}'; defaulted to DIALOGUE" + + +def round_duration(seconds: float) -> int: + """把浮点秒时长取整为 Jellyfish ShotDetail.duration 所需的整数秒(四舍五入,至少 1)。""" + value = int(round(seconds)) + return value if value >= 1 else 1 + + +def assemble_raw_text(package: EpisodePackage) -> str: + """确定性地把 EpisodePackage 组装成一段完整剧本文本,存入 Chapter.raw_text(仅供追溯)。 + + 算法(确定性、可复现,保留镜头顺序与可得内容): + 1. 首行 ``# {title}``;若有 ``logline`` 追加一行。 + 2. 镜头**按 sequence 升序**遍历;每镜输出一个空行分隔的小节: + a. 头部 ``[{sequence}] {title}``; + b. 若有 ``script_excerpt`` → 原样一行; + c. 若有 ``action`` → ``(action) {action}`` 一行; + d. 对白**按 order 升序**,逐行 ``{character_key or '—'}: {text}``。 + 3. 各部分以换行连接,整体首尾去空白。 + 仅在字段存在(非空)时输出,"where available"。不改写任何文本内容。 + """ + lines: list[str] = [f"# {package.title}".rstrip()] + if package.logline: + lines.append(package.logline) + for shot in sorted(package.shots, key=lambda s: s.sequence): + lines.append(f"\n[{shot.sequence}] {shot.title}".rstrip()) + if shot.script_excerpt: + lines.append(shot.script_excerpt) + if shot.action: + lines.append(f"(action) {shot.action}") + for line in sorted(shot.dialogue, key=lambda d: d.order): + speaker = line.character_key or "—" + lines.append(f"{speaker}: {line.text}") + return "\n".join(lines).strip() + + +def dialogue_speaker_name(package: EpisodePackage, line: DialogueLine) -> str | None: + """把对白的 character_key 解析为角色展示名(用于 ShotDialogLine.speaker_name 回填)。""" + if line.character_key is None: + return None + for character in package.characters: + if character.character_key == line.character_key: + return character.display_name + return None diff --git a/backend/app/crypto_animal_studio/integrations/__init__.py b/backend/app/crypto_animal_studio/integrations/__init__.py new file mode 100644 index 00000000..352556dd --- /dev/null +++ b/backend/app/crypto_animal_studio/integrations/__init__.py @@ -0,0 +1,6 @@ +"""CAS integrations 层(占位)。 + +预留与 Jellyfish 既有能力的桥接落点(如 Provider/Model/ModelSettings 适配)。 +按最终架构决策:CAS 最终收敛到 Jellyfish 的 Provider/Model/ModelSettings, +过渡期仅允许临时 adapter,**不**保留第二套独立 provider 配置系统。Sprint 2 暂无实现。 +""" diff --git a/backend/app/crypto_animal_studio/schemas/__init__.py b/backend/app/crypto_animal_studio/schemas/__init__.py new file mode 100644 index 00000000..4283b26e --- /dev/null +++ b/backend/app/crypto_animal_studio/schemas/__init__.py @@ -0,0 +1,37 @@ +"""CAS schemas 层:EpisodePackage v1 传输 / 校验模型。 + +职责:本层是 EpisodePackage 契约的**唯一** Pydantic 模型来源(domain 层不重复定义)。 +对外导出根模型与主要子模型,便于导入与测试。 +""" + +from app.crypto_animal_studio.schemas.episode_package import ( + ActorAsset, + AssetLibrary, + CameraSpec, + CharacterSpec, + CostumeAsset, + CreativeDirection, + DialogueLine, + EpisodeMetadata, + EpisodePackage, + NewsSource, + PropAsset, + SceneAsset, + Shot, +) + +__all__ = [ + "EpisodePackage", + "NewsSource", + "CreativeDirection", + "CharacterSpec", + "AssetLibrary", + "ActorAsset", + "SceneAsset", + "PropAsset", + "CostumeAsset", + "Shot", + "CameraSpec", + "DialogueLine", + "EpisodeMetadata", +] diff --git a/backend/app/crypto_animal_studio/schemas/episode_package.py b/backend/app/crypto_animal_studio/schemas/episode_package.py new file mode 100644 index 00000000..42263871 --- /dev/null +++ b/backend/app/crypto_animal_studio/schemas/episode_package.py @@ -0,0 +1,351 @@ +"""EpisodePackage v1 —— Creative OS(CAS) 与 Jellyfish 之间的严格契约。 + +用途: +- 定义 CAS 生成的「一集(Episode)」完整交付包的传输 / 校验模型。 +- 该契约是 Sprint 2 的核心产物;后续 Sprint 的同步导入 service 会消费本模型, + 把一个 EpisodePackage 映射为 Jellyfish 的一个 Chapter + 若干 Shot。 + +设计要点: +- 所有模型 ``extra="forbid"``:拒绝未知字段,尽早暴露契约漂移。 +- 字段级约束(非空 / 大于零 / 唯一序号等)尽量用 ``Field`` 表达; + **跨字段 / 跨引用**校验统一放在根模型的 ``model_validator(mode="after")``, + 一次性收集所有错误,便于调用方定位。 +- 版本号、来源类型等常量来自 ``crypto_animal_studio.domain``,不在本文件重复定义。 + +Pydantic:与仓库一致使用 Pydantic v2(``pydantic>=2.0``)。 +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.crypto_animal_studio.domain.episode_package import ( + SCHEMA_VERSION, + CasCameraAngle, + CasCameraMovement, + CasShotType, + SourceType, +) + + +# --------------------------------------------------------------------------- # +# 子模型 +# --------------------------------------------------------------------------- # +class NewsSource(BaseModel): + """一集的素材来源:新闻或原创设定的事实性上下文。 + + 仅承载「事实/触发点」,不含创意执行;便于追溯与审核。 + """ + + model_config = ConfigDict(extra="forbid") + + source_type: SourceType = Field(..., description="来源类型:news/original/fictional/generic") + headline: str = Field("", description="标题(新闻标题或原创触发点标题)") + summary: str = Field("", description="摘要:事件的中性概述") + source_url: Optional[str] = Field(None, description="来源链接(可选;原创内容可为空)") + published_at: Optional[str] = Field(None, description="发布时间(ISO-8601 字符串,可选)") + factual_notes: str = Field("", description="事实性备注:不得改写为投资建议或价格预测") + + +class CreativeDirection(BaseModel): + """一集的创意方向:格式、基调、时长目标与风格。""" + + model_config = ConfigDict(extra="forbid") + + format: str = Field("", description="内容格式(如 short_form_vertical)") + tone: str = Field("", description="整体基调(如 deadpan、satirical)") + target_duration_seconds: int = Field(..., gt=0, description="目标时长(秒),必须大于零") + visual_style: str = Field("", description="视觉风格(如 anime、cel-shaded)") + comedy_style: str = Field("", description="喜剧风格(如 false_confidence + callback)") + continuity_notes: str = Field("", description="连续性备注:跨集/跨镜需保持的设定") + + +class CharacterSpec(BaseModel): + """出场角色定义(叙事角色)。 + + ``character_key`` 为本集内稳定引用键;``actor_key`` / ``costume_key`` 指向素材库 + (视觉演员 / 服装),用于 Jellyfish 侧的一致性与选角映射。 + """ + + model_config = ConfigDict(extra="forbid") + + character_key: str = Field(..., min_length=1, description="角色键(本集内唯一,非空)") + display_name: str = Field(..., min_length=1, description="展示名(如 Bull)") + role: str = Field("", description="叙事角色定位(如 main、chaos_agent、straight_man)") + description: str = Field("", description="角色描述") + actor_key: Optional[str] = Field(None, description="对应 assets.actors 中的 actor_key(可选)") + costume_key: Optional[str] = Field(None, description="对应 assets.costumes 中的 costume_key(可选)") + voice_profile: Optional[str] = Field(None, description="声音设定(可选)") + continuity_notes: str = Field("", description="角色连续性备注(可选)") + + +class ActorAsset(BaseModel): + """视觉演员素材(跨角色/跨集可复用的视觉身份)。""" + + model_config = ConfigDict(extra="forbid") + + actor_key: str = Field(..., min_length=1, description="演员键(素材类别内唯一)") + display_name: str = Field("", description="展示名") + description: str = Field("", description="外观/视觉描述") + + +class SceneAsset(BaseModel): + """场景素材。""" + + model_config = ConfigDict(extra="forbid") + + scene_key: str = Field(..., min_length=1, description="场景键(素材类别内唯一)") + display_name: str = Field("", description="展示名") + description: str = Field("", description="场景描述") + + +class PropAsset(BaseModel): + """道具素材。""" + + model_config = ConfigDict(extra="forbid") + + prop_key: str = Field(..., min_length=1, description="道具键(素材类别内唯一)") + display_name: str = Field("", description="展示名") + description: str = Field("", description="道具描述") + + +class CostumeAsset(BaseModel): + """服装素材。""" + + model_config = ConfigDict(extra="forbid") + + costume_key: str = Field(..., min_length=1, description="服装键(素材类别内唯一)") + display_name: str = Field("", description="展示名") + description: str = Field("", description="服装描述") + + +class AssetLibrary(BaseModel): + """一集的素材库:演员 / 场景 / 道具 / 服装。""" + + model_config = ConfigDict(extra="forbid") + + actors: list[ActorAsset] = Field(default_factory=list, description="演员素材列表") + scenes: list[SceneAsset] = Field(default_factory=list, description="场景素材列表") + props: list[PropAsset] = Field(default_factory=list, description="道具素材列表") + costumes: list[CostumeAsset] = Field(default_factory=list, description="服装素材列表") + + +class DialogueLine(BaseModel): + """镜头内单条对白。 + + ``order`` 为镜头内排序(正整数、镜头内唯一);``character_key`` 若提供, + 必须能在 ``characters`` 中找到(根模型统一校验)。 + """ + + model_config = ConfigDict(extra="forbid") + + order: int = Field(..., gt=0, description="镜头内排序(正整数,镜头内唯一)") + character_key: Optional[str] = Field(None, description="说话角色键(可选;旁白可为空)") + text: str = Field(..., min_length=1, description="台词正文(非空)") + line_mode: str = Field("DIALOGUE", description="对白模式:DIALOGUE/VOICE_OVER/OFF_SCREEN/PHONE") + + +class CameraSpec(BaseModel): + """镜头的结构化相机描述。 + + v1.1 起将「camera 自由文本」升级为结构化对象,字段与 Jellyfish ShotDetail 的 + ``camera_shot`` / ``angle`` / ``movement`` 概念一一对应,便于导入器干净映射。 + 三个字段均可选(storyboard 未指定时留空);取值由 CAS 本地枚举校验, + **不**从 Jellyfish ORM/枚举导入。 + """ + + model_config = ConfigDict(extra="forbid") + + shot_type: Optional[CasShotType] = Field(None, description="景别(ECU/CU/MCU/MS/MLS/LS/ELS)") + angle: Optional[CasCameraAngle] = Field( + None, description="机位角度(EYE_LEVEL/HIGH_ANGLE/LOW_ANGLE/BIRD_EYE/DUTCH/OVER_SHOULDER)" + ) + movement: Optional[CasCameraMovement] = Field( + None, description="运镜(STATIC/PAN/TILT/DOLLY_IN/DOLLY_OUT/TRACK/CRANE/HANDHELD/STEADICAM/ZOOM_IN/ZOOM_OUT)" + ) + + +class Shot(BaseModel): + """一个镜头(storyboard 中的 shot),直接映射为 Jellyfish 的 Shot/ShotDetail。 + + 说明: + - ``camera`` 为结构化对象(``CameraSpec``),字段对齐 Jellyfish ShotDetail 的 + camera_shot/angle/movement,便于后续导入器映射;取值由 CAS 本地枚举校验。 + - ``duration_seconds`` 允许小数,必须大于零。 + """ + + model_config = ConfigDict(extra="forbid") + + shot_id: str = Field(..., min_length=1, description="镜头 ID(本集内唯一,非空)") + sequence: int = Field(..., gt=0, description="镜头顺序(正整数,本集内唯一)") + title: str = Field("", description="镜头标题/分镜名") + duration_seconds: float = Field(..., gt=0, description="镜头时长(秒),必须大于零") + script_excerpt: str = Field("", description="镜头对应的剧本摘录") + camera: Optional[CameraSpec] = Field(None, description="结构化相机描述(景别/角度/运镜,可选)") + action: str = Field("", description="镜头内动作/视觉描述") + dialogue: list[DialogueLine] = Field(default_factory=list, description="镜头内对白列表") + character_keys: list[str] = Field(default_factory=list, description="出场角色键(须存在于 characters)") + scene_key: Optional[str] = Field(None, description="场景键(可选;提供则须存在于 assets.scenes)") + prop_keys: list[str] = Field(default_factory=list, description="道具键(须存在于 assets.props)") + costume_keys: list[str] = Field(default_factory=list, description="服装键(须存在于 assets.costumes)") + image_prompt: str = Field("", description="图像生成提示词") + video_prompt: str = Field("", description="视频生成提示词") + negative_prompt: str = Field("", description="反向提示词") + continuity_notes: str = Field("", description="镜头连续性备注") + metadata: dict = Field(default_factory=dict, description="镜头级附加元信息") + + +class EpisodeMetadata(BaseModel): + """一集的生成元信息(用于追溯)。""" + + model_config = ConfigDict(extra="forbid") + + created_at: Optional[str] = Field(None, description="生成时间(ISO-8601 字符串,可选)") + generator: str = Field("", description="生成器标识(如 creative-os)") + model: str = Field("", description="所用模型标识") + prompt_version: str = Field("", description="提示词版本") + tags: list[str] = Field(default_factory=list, description="标签") + + +# --------------------------------------------------------------------------- # +# 根模型 +# --------------------------------------------------------------------------- # +class EpisodePackage(BaseModel): + """EpisodePackage v1 根对象:一集的完整交付包。 + + 一个 EpisodePackage 对应 Jellyfish 的一个 Chapter;其 ``shots`` 直接建立 + Jellyfish 的 Shot(不回送 ScriptDivider)。跨引用完整性由 ``_validate_cross_references`` + 统一校验。 + """ + + model_config = ConfigDict(extra="forbid") + + schema_version: str = Field(..., description='契约版本;v1 必须等于 "1.0"') + episode_id: str = Field(..., min_length=1, description="一集的唯一 ID(非空)") + title: str = Field(..., min_length=1, description="剧集标题(非空)") + logline: str = Field("", description="一句话梗概") + language: str = Field(..., min_length=1, description="语言(如 en、zh;非空)") + source: NewsSource = Field(..., description="素材来源") + creative_direction: CreativeDirection = Field(..., description="创意方向") + characters: list[CharacterSpec] = Field(..., description="出场角色(键须唯一)") + assets: AssetLibrary = Field(..., description="素材库") + shots: list[Shot] = Field(..., min_length=1, description="镜头列表(至少一个)") + metadata: EpisodeMetadata = Field(..., description="生成元信息") + + @field_validator("schema_version") + @classmethod + def _check_schema_version(cls, value: str) -> str: + """规则 1:schema_version 必须等于当前契约版本 "1.0"。""" + if value != SCHEMA_VERSION: + raise ValueError(f'schema_version must equal "{SCHEMA_VERSION}", got "{value}"') + return value + + @model_validator(mode="after") + def _validate_cross_references(self) -> "EpisodePackage": + """跨字段 / 跨引用完整性校验(规则 7–17)。 + + 一次性收集所有问题并抛出,覆盖: + - 角色键唯一(16) + - 各素材类别内 key 唯一(17) + - 镜头 sequence 正且唯一(7)、shot_id 唯一(8) + - 镜头内 dialogue.order 正且唯一(10) + - 镜头 character_keys / dialogue.character_key 必须存在于 characters(11) + - character.actor_key / costume_key 提供时须存在于对应素材(12、15) + - 镜头 scene_key 提供时须存在于 scenes(13) + - 镜头 prop_keys / costume_keys 须存在于对应素材(14、15) + """ + errors: list[str] = [] + + # --- 角色键唯一 & 集合 --- + character_keys = [c.character_key for c in self.characters] + _collect_duplicates(character_keys, "characters[].character_key", errors) + character_key_set = set(character_keys) + + # --- 素材键唯一 & 集合 --- + actor_keys = [a.actor_key for a in self.assets.actors] + scene_keys = [s.scene_key for s in self.assets.scenes] + prop_keys = [p.prop_key for p in self.assets.props] + costume_keys = [c.costume_key for c in self.assets.costumes] + _collect_duplicates(actor_keys, "assets.actors[].actor_key", errors) + _collect_duplicates(scene_keys, "assets.scenes[].scene_key", errors) + _collect_duplicates(prop_keys, "assets.props[].prop_key", errors) + _collect_duplicates(costume_keys, "assets.costumes[].costume_key", errors) + actor_key_set = set(actor_keys) + scene_key_set = set(scene_keys) + prop_key_set = set(prop_keys) + costume_key_set = set(costume_keys) + + # --- character 对素材的引用 --- + for character in self.characters: + if character.actor_key is not None and character.actor_key not in actor_key_set: + errors.append( + f"character '{character.character_key}' references unknown " + f"actor_key '{character.actor_key}'" + ) + if character.costume_key is not None and character.costume_key not in costume_key_set: + errors.append( + f"character '{character.character_key}' references unknown " + f"costume_key '{character.costume_key}'" + ) + + # --- 镜头级校验 --- + sequences = [shot.sequence for shot in self.shots] + _collect_duplicates(sequences, "shots[].sequence", errors) + shot_ids = [shot.shot_id for shot in self.shots] + _collect_duplicates(shot_ids, "shots[].shot_id", errors) + + for shot in self.shots: + where = f"shot '{shot.shot_id}'" + + # dialogue.order 正且唯一(正性已由 Field(gt=0) 保证,这里查唯一) + _collect_duplicates( + [line.order for line in shot.dialogue], f"{where} dialogue.order", errors + ) + + # 出场角色键须存在 + for key in shot.character_keys: + if key not in character_key_set: + errors.append(f"{where} references unknown character_key '{key}'") + + # 对白说话人须存在(若提供) + for line in shot.dialogue: + if line.character_key is not None and line.character_key not in character_key_set: + errors.append( + f"{where} dialogue order {line.order} references unknown " + f"character_key '{line.character_key}'" + ) + + # 场景 / 道具 / 服装引用 + if shot.scene_key is not None and shot.scene_key not in scene_key_set: + errors.append(f"{where} references unknown scene_key '{shot.scene_key}'") + for key in shot.prop_keys: + if key not in prop_key_set: + errors.append(f"{where} references unknown prop_key '{key}'") + for key in shot.costume_keys: + if key not in costume_key_set: + errors.append(f"{where} references unknown costume_key '{key}'") + + if errors: + raise ValueError("EpisodePackage cross-reference validation failed: " + "; ".join(errors)) + return self + + +def _collect_duplicates(values: list, where: str, errors: list[str]) -> None: + """辅助:把 ``values`` 中的重复项以可读信息追加到 ``errors``。 + + 参数: + values: 待检查的键/序号列表。 + where: 出错位置的描述(用于定位)。 + errors: 错误累积列表(就地追加)。 + """ + seen: set = set() + dups: set = set() + for value in values: + if value in seen: + dups.add(value) + seen.add(value) + if dups: + rendered = ", ".join(str(d) for d in sorted(dups, key=str)) + errors.append(f"{where} contains duplicate values: {rendered}") diff --git a/backend/app/crypto_animal_studio/schemas/import_request.py b/backend/app/crypto_animal_studio/schemas/import_request.py new file mode 100644 index 00000000..34f8faba --- /dev/null +++ b/backend/app/crypto_animal_studio/schemas/import_request.py @@ -0,0 +1,21 @@ +"""导入请求模型(schemas 层)。""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage + + +class ImportEpisodeRequest(BaseModel): + """POST /api/v1/crypto-animal-studio/import 的请求体。""" + + model_config = ConfigDict(extra="forbid") + + project_id: str = Field(..., min_length=1, description="目标 Jellyfish 项目 ID(系列/季)") + episode_package: EpisodePackage = Field(..., description="待导入的 EpisodePackage(严格校验)") + dry_run: bool = Field(False, description="为真时只校验/映射/复用查找/告警,不写库") + idempotency_key: str = Field(..., min_length=1, description="幂等键") + + +__all__ = ["ImportEpisodeRequest"] diff --git a/backend/app/crypto_animal_studio/schemas/import_result.py b/backend/app/crypto_animal_studio/schemas/import_result.py new file mode 100644 index 00000000..19577a80 --- /dev/null +++ b/backend/app/crypto_animal_studio/schemas/import_result.py @@ -0,0 +1,14 @@ +"""导入响应模型(schemas 层)。 + +导入结果的传输结构就是 application 层的 ``ImportResult``;此处 re-export 以保持 +「api 依赖 schemas」的分层习惯,避免重复定义模型。 +""" + +from __future__ import annotations + +from app.crypto_animal_studio.application.import_result import ImportCounts, ImportResult + +# API 语义别名(响应 data 即 ImportResult)。 +ImportEpisodeResponse = ImportResult + +__all__ = ["ImportEpisodeResponse", "ImportResult", "ImportCounts"] diff --git a/backend/sql/009-add-cas-import-ledger.sql b/backend/sql/009-add-cas-import-ledger.sql new file mode 100644 index 00000000..2910fe8e --- /dev/null +++ b/backend/sql/009-add-cas-import-ledger.sql @@ -0,0 +1,28 @@ +-- 009-add-cas-import-ledger.sql +-- Crypto Animal Studio (CAS) EpisodePackage 导入台账表。 +-- 目的:为导入提供持久化幂等(durable idempotency)。经用户批准新增此轻量记账表。 +-- 说明:仅记账,不复制任何 Project/Shot/Asset 业务实体;引用既有 projects / chapters。 + +CREATE TABLE IF NOT EXISTS `cas_import_ledger` ( + `id` VARCHAR(64) NOT NULL COMMENT '台账行 ID(UUID)', + `project_id` VARCHAR(64) NOT NULL COMMENT '所属项目 ID', + `episode_id` VARCHAR(255) NOT NULL COMMENT 'CAS Episode ID', + `idempotency_key` VARCHAR(255) NOT NULL COMMENT '幂等键', + `payload_hash` VARCHAR(64) NOT NULL COMMENT 'EpisodePackage 规范化序列化的 SHA-256 十六进制', + `chapter_id` VARCHAR(64) NULL COMMENT '导入产生的 Chapter ID(章节删除时置空)', + `status` VARCHAR(32) NOT NULL DEFAULT 'imported' COMMENT '导入状态', + `schema_version` VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'EpisodePackage 契约版本', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + -- 唯一约束(project_id 作为最左前缀,同时满足 project 外键所需索引) + UNIQUE KEY `uq_cas_import_project_key` (`project_id`, `idempotency_key`), + UNIQUE KEY `uq_cas_import_project_episode` (`project_id`, `episode_id`), + -- chapter 外键需要独立索引;payload_hash 便于按内容排查 + KEY `ix_cas_import_chapter_id` (`chapter_id`), + KEY `ix_cas_import_payload_hash` (`payload_hash`), + CONSTRAINT `fk_cas_import_project` + FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_cas_import_chapter` + FOREIGN KEY (`chapter_id`) REFERENCES `chapters` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CAS EpisodePackage 导入台账'; diff --git a/backend/tests/test_cas_episode_package_schema.py b/backend/tests/test_cas_episode_package_schema.py new file mode 100644 index 00000000..e61de653 --- /dev/null +++ b/backend/tests/test_cas_episode_package_schema.py @@ -0,0 +1,217 @@ +"""EpisodePackage v1 schema 校验测试。 + +覆盖:有效样本加载、版本号、序号/ID 唯一性、跨引用完整性、时长约束、未知字段拒绝。 +不依赖 FastAPI app 或数据库;仅校验 ``crypto_animal_studio.schemas`` 契约。 +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage + +# 仓库根:tests -> backend -> +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SAMPLE_PATH = _REPO_ROOT / "docs" / "crypto-animal-studio" / "samples" / "sample-episode-package-v1.json" + + +def _load_sample() -> dict: + """加载有效样本 EpisodePackage 为 dict(每次返回独立深拷贝,便于就地改造出错例)。""" + with _SAMPLE_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def test_valid_sample_loads_successfully() -> None: + """有效样本应通过全部校验并成功构造模型。""" + pkg = EpisodePackage.model_validate(_load_sample()) + assert pkg.schema_version == "1.0" + assert pkg.episode_id == "CAS-E001" + assert len(pkg.shots) >= 3 + # 样本至少包含 3 位常驻角色 + assert {"bull", "bear", "walter"}.issubset({c.character_key for c in pkg.characters}) + + +def test_invalid_schema_version_fails() -> None: + """schema_version 非 "1.0" 应失败(规则 1)。""" + data = _load_sample() + data["schema_version"] = "2.0" + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_empty_required_field_fails() -> None: + """必填非空字段为空应失败(规则 2/3/4)。""" + data = _load_sample() + data["episode_id"] = "" + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_zero_target_duration_fails() -> None: + """creative_direction.target_duration_seconds 必须大于零(规则 5)。""" + data = _load_sample() + data["creative_direction"]["target_duration_seconds"] = 0 + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_empty_shots_fails() -> None: + """shots 至少一个(规则 6)。""" + data = _load_sample() + data["shots"] = [] + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_duplicate_shot_sequence_fails() -> None: + """镜头 sequence 必须唯一(规则 7)。""" + data = _load_sample() + data["shots"][1]["sequence"] = data["shots"][0]["sequence"] + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_duplicate_shot_id_fails() -> None: + """shot_id 必须唯一(规则 8)。""" + data = _load_sample() + data["shots"][1]["shot_id"] = data["shots"][0]["shot_id"] + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_zero_or_negative_shot_duration_fails() -> None: + """镜头 duration_seconds 必须大于零(规则 9)。""" + data = _load_sample() + data["shots"][0]["duration_seconds"] = 0 + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_duplicate_dialogue_order_fails() -> None: + """镜头内 dialogue.order 必须唯一(规则 10)。""" + data = _load_sample() + lines = data["shots"][0]["dialogue"] + lines[1]["order"] = lines[0]["order"] + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_character_reference_fails() -> None: + """镜头 character_keys 引用不存在的角色应失败(规则 11)。""" + data = _load_sample() + data["shots"][0]["character_keys"].append("ghost") + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_actor_reference_fails() -> None: + """character.actor_key 引用不存在的演员应失败(规则 12)。""" + data = _load_sample() + data["characters"][0]["actor_key"] = "actor_missing" + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_scene_reference_fails() -> None: + """镜头 scene_key 引用不存在的场景应失败(规则 13)。""" + data = _load_sample() + data["shots"][0]["scene_key"] = "scene_missing" + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_prop_reference_fails() -> None: + """镜头 prop_keys 引用不存在的道具应失败(规则 14)。""" + data = _load_sample() + data["shots"][0]["prop_keys"].append("prop_missing") + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_costume_reference_fails() -> None: + """镜头 costume_keys 引用不存在的服装应失败(规则 15)。""" + data = _load_sample() + data["shots"][0]["costume_keys"].append("costume_missing") + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_duplicate_character_key_fails() -> None: + """character_key 必须唯一(规则 16)。""" + data = _load_sample() + dup = copy.deepcopy(data["characters"][0]) + data["characters"].append(dup) + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_duplicate_asset_key_fails() -> None: + """素材类别内 key 必须唯一(规则 17)。""" + data = _load_sample() + dup = copy.deepcopy(data["assets"]["props"][0]) + data["assets"]["props"].append(dup) + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_field_is_rejected() -> None: + """未知字段应被拒绝(规则 18,extra="forbid")。""" + data = _load_sample() + data["unexpected_field"] = "nope" + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_nested_field_is_rejected() -> None: + """嵌套模型的未知字段同样应被拒绝(规则 18)。""" + data = _load_sample() + data["shots"][0]["bogus"] = 1 + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_structured_camera_is_parsed() -> None: + """camera 为结构化对象,应解析为 shot_type/angle/movement。""" + pkg = EpisodePackage.model_validate(_load_sample()) + cam = pkg.shots[0].camera + assert cam is not None + assert cam.shot_type.value == "MS" + assert cam.angle.value == "EYE_LEVEL" + assert cam.movement.value == "STATIC" + + +def test_camera_is_optional() -> None: + """camera 可省略(storyboard 未指定时留空)。""" + data = _load_sample() + data["shots"][0].pop("camera", None) + pkg = EpisodePackage.model_validate(data) + assert pkg.shots[0].camera is None + + +def test_invalid_camera_shot_type_fails() -> None: + """camera.shot_type 取值必须属于 CAS 枚举,否则失败。""" + data = _load_sample() + data["shots"][0]["camera"]["shot_type"] = "WIDE" # 非法景别 + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_invalid_camera_movement_fails() -> None: + """camera.movement 取值必须属于 CAS 枚举,否则失败。""" + data = _load_sample() + data["shots"][0]["camera"]["movement"] = "FLY" # 非法运镜 + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) + + +def test_unknown_camera_field_rejected() -> None: + """camera 对象内的未知字段应被拒绝(extra="forbid")。""" + data = _load_sample() + data["shots"][0]["camera"]["zoom_ratio"] = 2 + with pytest.raises(ValidationError): + EpisodePackage.model_validate(data) diff --git a/backend/tests/test_cas_health_api.py b/backend/tests/test_cas_health_api.py new file mode 100644 index 00000000..9a729fd9 --- /dev/null +++ b/backend/tests/test_cas_health_api.py @@ -0,0 +1,31 @@ +"""CAS 健康端点 API 测试。 + +覆盖:端点返回成功、响应使用 ApiResponse 壳、data 含 service/status/schema_version。 +使用仓库既有的 ``client`` fixture(TestClient);若 app 依赖未满足会自动跳过。 +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_cas_health_returns_success(client: TestClient) -> None: + """GET /api/v1/crypto-animal-studio/health 应返回 200 且 code=200。""" + resp = client.get("/api/v1/crypto-animal-studio/health") + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + + +def test_cas_health_uses_api_response_envelope(client: TestClient) -> None: + """响应应使用统一 ApiResponse 壳:包含 code / message / data 字段。""" + body = client.get("/api/v1/crypto-animal-studio/health").json() + assert set(["code", "message", "data"]).issubset(body.keys()) + + +def test_cas_health_data_fields(client: TestClient) -> None: + """data 应包含 service / status / schema_version 且取值正确。""" + data = client.get("/api/v1/crypto-animal-studio/health").json()["data"] + assert data["service"] == "crypto-animal-studio" + assert data["status"] == "ok" + assert data["schema_version"] == "1.0" diff --git a/backend/tests/test_cas_import_api.py b/backend/tests/test_cas_import_api.py new file mode 100644 index 00000000..7ce7b069 --- /dev/null +++ b/backend/tests/test_cas_import_api.py @@ -0,0 +1,91 @@ +"""CAS 导入 API 路由测试:ApiResponse 外壳与错误翻译(薄路由)。 + +用最小 FastAPI app 挂载 CAS 路由并覆盖 get_db,避免拉起完整应用; +用 monkeypatch 替换 application 层导入服务,聚焦验证「路由 + 响应壳 + 异常→HTTP」。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import app.crypto_animal_studio.api.import_episode as route +from app.crypto_animal_studio.api import router as cas_router +from app.crypto_animal_studio.application.import_episode import ProjectNotFoundError +from app.crypto_animal_studio.application.import_result import ImportCounts, ImportResult +from app.dependencies import get_db + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SAMPLE = _REPO_ROOT / "docs" / "crypto-animal-studio" / "samples" / "sample-episode-package-v1.json" + + +async def _fake_db(): + """占位会话(被 monkeypatch 的服务不会真正使用它)。""" + yield object() + + +def _make_client() -> TestClient: + app = FastAPI() + app.include_router(cas_router, prefix="/api/v1/crypto-animal-studio") + app.dependency_overrides[get_db] = _fake_db + return TestClient(app) + + +def _request_body(dry_run: bool = True) -> dict: + return { + "project_id": "proj-1", + "episode_package": json.loads(_SAMPLE.read_text(encoding="utf-8")), + "dry_run": dry_run, + "idempotency_key": "k1", + } + + +def test_import_endpoint_success_envelope(monkeypatch) -> None: + """成功路径:返回 200 + ApiResponse 外壳 + data 为 ImportResult。""" + + async def _fake_import(db, *, project_id, package, idempotency_key, dry_run=False): + return ImportResult( + status="dry_run", + dry_run=True, + idempotent_replay=False, + project_id=project_id, + episode_id=package.episode_id, + idempotency_key=idempotency_key, + payload_hash="0" * 64, + chapter_id=None, + chapter_index=1, + created=ImportCounts(shots=4), + reused=ImportCounts(), + warnings=[], + ) + + monkeypatch.setattr(route, "import_episode", _fake_import) + resp = _make_client().post("/api/v1/crypto-animal-studio/import", json=_request_body()) + assert resp.status_code == 200 + body = resp.json() + assert {"code", "message", "data"}.issubset(body.keys()) + assert body["code"] == 200 + assert body["data"]["status"] == "dry_run" + assert body["data"]["created"]["shots"] == 4 + + +def test_import_endpoint_project_not_found_maps_404(monkeypatch) -> None: + """项目不存在 → 404,且仍是 ApiResponse 外壳。""" + + async def _raise(db, *, project_id, package, idempotency_key, dry_run=False): + raise ProjectNotFoundError("Project not found: proj-1") + + monkeypatch.setattr(route, "import_episode", _raise) + resp = _make_client().post("/api/v1/crypto-animal-studio/import", json=_request_body()) + assert resp.status_code == 404 + + +def test_import_endpoint_rejects_unknown_body_field() -> None: + """请求体未知字段被拒绝(extra=forbid)→ 422。""" + bad = _request_body() + bad["surprise"] = 1 + resp = _make_client().post("/api/v1/crypto-animal-studio/import", json=bad) + assert resp.status_code == 422 diff --git a/backend/tests/test_cas_import_episode.py b/backend/tests/test_cas_import_episode.py new file mode 100644 index 00000000..dff78866 --- /dev/null +++ b/backend/tests/test_cas_import_episode.py @@ -0,0 +1,349 @@ +"""CAS EpisodePackage 导入器集成测试(SQLite,单事务)。 + +覆盖:成功导入、dry-run 不写库、幂等重放、幂等冲突、跨 key 重复 episode、相机映射、 +对白映射、资产复用、章节/镜头创建、错误回滚、项目不存在。 + +用同步测试函数内 ``asyncio.run`` 驱动异步会话,避免 pytest-asyncio 事件循环夹具的复杂度。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +import app.crypto_animal_studio.application.import_episode as ie +from app.core.db import Base +from app.crypto_animal_studio.application.import_episode import ( + EpisodeAlreadyImportedError, + IdempotencyConflictError, + ProjectNotFoundError, + import_episode, +) +from app.crypto_animal_studio.domain.import_ledger import CasImportLedger +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage +from app.models.studio import ( + Actor, + Chapter, + Character, + Costume, + Project, + Prop, + Scene, + Shot, + ShotDetail, + ShotDialogLine, +) +from app.models.types import ProjectStyle, ProjectVisualStyle + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SAMPLE = _REPO_ROOT / "docs" / "crypto-animal-studio" / "samples" / "sample-episode-package-v1.json" + + +def _sample_dict() -> dict: + return json.loads(_SAMPLE.read_text(encoding="utf-8")) + + +async def _make_sessionmaker(): + """建内存 SQLite(StaticPool 共享单连接)并创建全部表,返回 (engine, Session)。""" + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + # 注册模型到 Base.metadata + import app.models.studio # noqa: F401 + import app.models.llm # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + import app.crypto_animal_studio.domain.import_ledger # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +async def _seed_project(Session, project_id: str = "proj-1") -> str: + async with Session() as db: + db.add( + Project( + id=project_id, + name="Crypto Animal Street (Season 1)", + style=ProjectStyle.anime_3d, + visual_style=ProjectVisualStyle.anime, + ) + ) + await db.commit() + return project_id + + +async def _count(Session, model) -> int: + async with Session() as db: + return int((await db.execute(select(func.count()).select_from(model))).scalar() or 0) + + +# --------------------------------------------------------------------------- # +def test_successful_import_creates_chapter_and_shots() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + pkg = EpisodePackage.model_validate(_sample_dict()) + async with Session() as db: + result = await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.commit() + assert result.status == "imported" + assert result.chapter_id is not None + assert result.created.shots == 4 + assert result.created.dialog_lines >= 4 + assert result.created.characters == 3 + assert result.created.actors == 3 + assert result.created.scenes == 1 + assert result.created.props >= 2 + assert result.created.costumes >= 2 + assert result.created.links > 0 + # 持久化核对 + assert await _count(Session, Chapter) == 1 + assert await _count(Session, Shot) == 4 + assert await _count(Session, ShotDetail) == 4 + assert await _count(Session, ShotDialogLine) >= 4 + assert await _count(Session, Character) == 3 + assert await _count(Session, CasImportLedger) == 1 + await engine.dispose() + + asyncio.run(_run()) + + +def test_dry_run_writes_nothing() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + pkg = EpisodePackage.model_validate(_sample_dict()) + async with Session() as db: + result = await import_episode( + db, project_id=pid, package=pkg, idempotency_key="k1", dry_run=True + ) + await db.commit() # 模拟 get_db 的最终提交(服务内部已 rollback → 空提交) + assert result.status == "dry_run" + assert result.dry_run is True + assert result.chapter_id is None + assert result.chapter_index == 1 + assert result.created.shots == 4 # 报告“将创建”的计数 + assert await _count(Session, Chapter) == 0 + assert await _count(Session, Shot) == 0 + assert await _count(Session, CasImportLedger) == 0 + await engine.dispose() + + asyncio.run(_run()) + + +def test_idempotent_replay_returns_existing() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + pkg = EpisodePackage.model_validate(_sample_dict()) + async with Session() as db: + first = await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.commit() + async with Session() as db: + replay = await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.commit() + assert replay.status == "replayed" + assert replay.idempotent_replay is True + assert replay.chapter_id == first.chapter_id + assert await _count(Session, Chapter) == 1 # 无重复章节 + await engine.dispose() + + asyncio.run(_run()) + + +def test_same_key_different_payload_conflicts() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + async with Session() as db: + await import_episode( + db, project_id=pid, package=EpisodePackage.model_validate(_sample_dict()), + idempotency_key="k1", + ) + await db.commit() + changed = _sample_dict() + changed["title"] = "Different Title" + async with Session() as db: + with pytest.raises(IdempotencyConflictError): + await import_episode( + db, project_id=pid, package=EpisodePackage.model_validate(changed), + idempotency_key="k1", + ) + await engine.dispose() + + asyncio.run(_run()) + + +def test_same_episode_other_key_rejected() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + async with Session() as db: + await import_episode( + db, project_id=pid, package=EpisodePackage.model_validate(_sample_dict()), + idempotency_key="k1", + ) + await db.commit() + async with Session() as db: + with pytest.raises(EpisodeAlreadyImportedError): + await import_episode( + db, project_id=pid, package=EpisodePackage.model_validate(_sample_dict()), + idempotency_key="k2-different", + ) + await engine.dispose() + + asyncio.run(_run()) + + +def test_camera_missing_defaults_and_warns() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + data = _sample_dict() + data["shots"][0].pop("camera", None) # 移除首镜 camera + pkg = EpisodePackage.model_validate(data) + async with Session() as db: + result = await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.commit() + assert any("camera missing" in w for w in result.warnings) + # 首镜 ShotDetail 使用默认相机 + async with Session() as db: + details = (await db.execute(select(ShotDetail))).scalars().all() + defaulted = [d for d in details if d.camera_shot == "MS" and d.movement == "STATIC"] + assert defaulted, "expected a shot detail with defaulted camera" + await engine.dispose() + + asyncio.run(_run()) + + +def test_dialogue_mapping_persists_lines_and_speakers() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + pkg = EpisodePackage.model_validate(_sample_dict()) + async with Session() as db: + await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.commit() + async with Session() as db: + lines = (await db.execute(select(ShotDialogLine))).scalars().all() + assert len(lines) >= 4 + assert all(l.text for l in lines) + assert any(l.speaker_name for l in lines) # 说话人名回填 + assert any(l.speaker_character_id for l in lines) # 关联到角色 + await engine.dispose() + + asyncio.run(_run()) + + +def test_asset_reuse_across_episodes_same_project() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + async with Session() as db: + await import_episode( + db, project_id=pid, package=EpisodePackage.model_validate(_sample_dict()), + idempotency_key="k1", + ) + await db.commit() + # 第二集:不同 episode_id + 不同 key,但角色/演员同名 → 复用 + data2 = _sample_dict() + data2["episode_id"] = "CAS-E002" + async with Session() as db: + result2 = await import_episode( + db, project_id=pid, package=EpisodePackage.model_validate(data2), + idempotency_key="k2", + ) + await db.commit() + assert result2.reused.actors >= 1 + assert result2.reused.characters >= 1 + assert result2.created.actors == 0 # 全部复用,未重复创建 + # 全库只有一份 3 个 Actor(未翻倍) + assert await _count(Session, Actor) == 3 + await engine.dispose() + + asyncio.run(_run()) + + +def test_error_midway_rolls_back_everything(monkeypatch) -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + pkg = EpisodePackage.model_validate(_sample_dict()) + + calls = {"n": 0} + real = ie.create_and_refresh + + async def failing(db, obj): + calls["n"] += 1 + if calls["n"] >= 6: # 在写入若干行之后失败 + raise RuntimeError("boom") + return await real(db, obj) + + monkeypatch.setattr(ie, "create_and_refresh", failing) + async with Session() as db: + with pytest.raises(RuntimeError): + await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.rollback() # 模拟 get_db 异常时回滚 + # 无部分写入 + assert await _count(Session, Chapter) == 0 + assert await _count(Session, Shot) == 0 + assert await _count(Session, CasImportLedger) == 0 + await engine.dispose() + + asyncio.run(_run()) + + +def test_ledger_insert_failure_rolls_back_entire_episode(monkeypatch) -> None: + """台账行写入失败 → 整集(Chapter/Shots/Details/对白/资产/链接)全部回滚。 + + 证明 ledger 与全部业务写入共用同一 AsyncSession、处于同一个事务: + ledger 是导入的最后一步,令其失败后回滚,库中应无任何本次导入的数据。 + """ + + async def _run(): + engine, Session = await _make_sessionmaker() + pid = await _seed_project(Session) + pkg = EpisodePackage.model_validate(_sample_dict()) + + real = ie.create_and_refresh + + async def failing(db, obj): + # 仅在写入台账行时失败;此前所有业务写入都在同一事务内。 + if isinstance(obj, CasImportLedger): + raise RuntimeError("ledger insert boom") + return await real(db, obj) + + monkeypatch.setattr(ie, "create_and_refresh", failing) + async with Session() as db: + with pytest.raises(RuntimeError): + await import_episode(db, project_id=pid, package=pkg, idempotency_key="k1") + await db.rollback() # 模拟 get_db 异常时的回滚 + + # 全部回滚:没有任何部分写入 + for model in (Chapter, Shot, ShotDetail, ShotDialogLine, Character, Actor, Scene, Prop, Costume, CasImportLedger): + assert await _count(Session, model) == 0, f"{model.__name__} not rolled back" + await engine.dispose() + + asyncio.run(_run()) + + +def test_project_not_found() -> None: + async def _run(): + engine, Session = await _make_sessionmaker() + pkg = EpisodePackage.model_validate(_sample_dict()) + async with Session() as db: + with pytest.raises(ProjectNotFoundError): + await import_episode(db, project_id="missing", package=pkg, idempotency_key="k1") + await engine.dispose() + + asyncio.run(_run()) diff --git a/backend/tests/test_cas_import_unit.py b/backend/tests/test_cas_import_unit.py new file mode 100644 index 00000000..adca938b --- /dev/null +++ b/backend/tests/test_cas_import_unit.py @@ -0,0 +1,81 @@ +"""CAS 导入器纯逻辑单测:canonical 哈希与 domain 映射(不触库、不依赖 app)。""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +from app.crypto_animal_studio.application.hashing import canonical_payload_hash +from app.crypto_animal_studio.domain import mapping +from app.crypto_animal_studio.schemas.episode_package import CameraSpec, EpisodePackage + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SAMPLE = _REPO_ROOT / "docs" / "crypto-animal-studio" / "samples" / "sample-episode-package-v1.json" + + +def _load_pkg() -> EpisodePackage: + return EpisodePackage.model_validate(json.loads(_SAMPLE.read_text(encoding="utf-8"))) + + +# --- hashing --- +def test_hash_is_deterministic() -> None: + """相同 payload → 相同哈希(与字段书写顺序无关)。""" + assert canonical_payload_hash(_load_pkg()) == canonical_payload_hash(_load_pkg()) + + +def test_hash_changes_on_payload_change() -> None: + """payload 变化 → 哈希变化。""" + data = json.loads(_SAMPLE.read_text(encoding="utf-8")) + h1 = canonical_payload_hash(EpisodePackage.model_validate(data)) + data["title"] = data["title"] + " (edit)" + h2 = canonical_payload_hash(EpisodePackage.model_validate(data)) + assert h1 != h2 + assert len(h1) == 64 # sha256 hex + + +# --- mapping --- +def test_normalize_key_trims_and_lowercases() -> None: + assert mapping.normalize_key(" Bull ") == "bull" + assert mapping.normalize_key("WALTER") == "walter" + + +def test_resolve_camera_defaults_and_warns_when_missing() -> None: + shot_type, angle, movement, warnings = mapping.resolve_camera(None) + assert (shot_type, angle, movement) == ("MS", "EYE_LEVEL", "STATIC") + assert warnings and "camera missing" in warnings[0] + + +def test_resolve_camera_uses_provided_values() -> None: + cam = CameraSpec(shot_type="CU", angle="LOW_ANGLE", movement="PAN") + shot_type, angle, movement, warnings = mapping.resolve_camera(cam) + assert (shot_type, angle, movement) == ("CU", "LOW_ANGLE", "PAN") + assert warnings == [] + + +def test_resolve_line_mode_valid_and_invalid() -> None: + assert mapping.resolve_line_mode("VOICE_OVER") == ("VOICE_OVER", None) + code, warning = mapping.resolve_line_mode("SINGING") + assert code == "DIALOGUE" and warning is not None + + +def test_round_duration_minimum_one() -> None: + assert mapping.round_duration(8.4) == 8 + assert mapping.round_duration(0.2) == 1 + + +def test_assemble_raw_text_is_nonempty_and_ordered() -> None: + pkg = _load_pkg() + text = mapping.assemble_raw_text(pkg) + assert "Champagne Before Confirmation" in text + # 镜头按 sequence 顺序出现 + assert text.index("[1]") < text.index("[2]") < text.index("[3]") + # 保留 action 与对白 + assert "(action)" in text + assert pkg.shots[0].action in text + assert pkg.shots[0].dialogue[0].text in text + + +def test_assemble_raw_text_is_deterministic() -> None: + pkg = _load_pkg() + assert mapping.assemble_raw_text(pkg) == mapping.assemble_raw_text(_load_pkg()) diff --git a/docs/adr/ADR-012-episode-importer.md b/docs/adr/ADR-012-episode-importer.md new file mode 100644 index 00000000..8f52951e --- /dev/null +++ b/docs/adr/ADR-012-episode-importer.md @@ -0,0 +1,257 @@ +# ADR-012 — EpisodePackage Importer Design + +- Status: **Accepted** (authoritative design for the EpisodePackage importer; implemented + in Sprint 3 — importer, ledger, API, tests). +- Date: 2026-07-24 +- Deciders: CAS × Jellyfish integration +- Module: `backend/app/crypto_animal_studio/` +- Related: `MASTER_PLAN.md`, `docs/architecture-analysis.md` (Final Architecture Decisions), + `docs/crypto-animal-studio/episode-package-v1.md` (EpisodePackage v1 contract). + +--- + +## 1. Context + +**Why EpisodePackage is the official interchange format.** +Creative OS (CAS) and Jellyfish are two systems with different internal models. CAS +reasons about comedy: beats, escalation, punchline ownership, character voice. Jellyfish +reasons about production: chapters, shots, assets, generation tasks. A stable, versioned, +strictly-validated contract is required at the seam so neither side leaks its internal +representation into the other. **EpisodePackage v1** (see the contract doc) is that +contract: a single JSON document that fully describes one episode — script, storyboard +shots, dialogue, characters, and asset references — with strict Pydantic validation and +cross-reference integrity. Making it the *only* supported import format keeps the +integration surface small, testable, and independent of how CAS was generated. + +**Why import directly instead of reconstructing via AI agents.** +Jellyfish natively ingests a raw script and uses `ScriptDividerAgent` to cut it into +shots and `ElementExtractorAgent` to infer characters/scenes/props. That is correct for +*unstructured* input. But a CAS EpisodePackage is **already a finished storyboard**: its +shot boundaries, timing, dialogue-to-shot alignment, and comedy beats are deliberate +authored decisions. Re-running those decisions through non-deterministic AI agents would: + +- re-cut shots and destroy intended comedy beats and pacing; +- re-time or re-order dialogue, breaking punchline placement; +- re-infer entities, producing drift from the CAS-authored cast; +- make the same input produce different Jellyfish output on each run. + +Therefore the importer is a **deterministic, mechanical mapper**, not an inference step. +It transcribes an already-decided structure into Jellyfish's data model. + +## 2. Decision + +1. **EpisodePackage is the only supported import contract.** No other CAS→Jellyfish + import path is supported. +2. **A CAS Episode maps directly to one Jellyfish Chapter.** +3. **A Jellyfish Project represents a production, season, or series** and may contain + multiple CAS Chapters/Episodes. +4. **EpisodePackage Shots are imported directly** into Jellyfish `Shot` / `ShotDetail` / + `ShotDialogLine`. +5. **`ScriptDividerAgent` is NOT invoked** by the importer. +6. **`ElementExtractorAgent` is NOT invoked** by the importer. +7. **`Chapter.raw_text` stores the original complete script** (the CAS-generated episode + script), for traceability only — it is never re-divided. +8. **The importer is deterministic**: identical input always yields identical Jellyfish + state. +9. **The importer never invokes LLMs.** +10. **The importer never modifies script content** (no rewriting of dialogue, prompts, or + narrative text). + +## 3. Architecture + +``` +Creative OS + │ (authors a complete episode) + ▼ +EpisodePackage (v1 JSON contract) + │ + ▼ +Validation ── EpisodePackage Pydantic model (strict, extra="forbid", + │ cross-reference checks). Invalid → reject, nothing written. + ▼ +CAS Importer ── deterministic mapper (application layer, bounded module). + │ No LLM, no agents, no providers, no Celery/Redis. + ▼ +Jellyfish Services ── existing services/studio/* (chapters, shots, shot_details, + │ shot_dialogs, entities). Reuse, do not duplicate. + ▼ +Database ── single transaction: Chapter + Shots + ShotDetail + ShotDialogLine + + Character/asset links. All-or-nothing. +``` + +The importer lives in `backend/app/crypto_animal_studio/application/` and calls existing +Jellyfish studio services; the API entry is a thin route in +`backend/app/crypto_animal_studio/api/`. No parallel systems are introduced. + +## 4. Importer Responsibilities + +**The importer may:** + +- **validate** — parse and strictly validate the EpisodePackage (contract rules). +- **map** — translate contract fields to Jellyfish model fields (deterministic). +- **reuse** — resolve existing Jellyfish assets/entities by normalized key before creating. +- **create** — create the Chapter, Shots, ShotDetail, ShotDialogLine, and required + entities/links that do not already exist. +- **rollback** — abort the whole operation and leave the database unchanged on any failure. + +**The importer must never:** + +- rewrite prompts +- rewrite dialogue +- generate assets +- call providers +- invoke Celery +- invoke Redis +- invoke `ScriptDividerAgent` +- invoke `ElementExtractorAgent` + +## 5. Mapping Strategy + +Deterministic field mapping (contract → Jellyfish). Existing services own the writes. + +``` +EpisodePackage → Jellyfish Chapter + episode_id → chapter identity / traceable id + title → Chapter.title + (assembled full script) → Chapter.raw_text (traceability only) + logline / source / notes → Chapter.summary (as appropriate) + +EpisodePackage.shots[] → Jellyfish Shot (+ ShotDetail, +ShotDialogLine) + shot_id / sequence → Shot identity / Shot.index (章节内唯一) + title → Shot.title + script_excerpt → Shot.script_excerpt + duration_seconds → ShotDetail.duration + action / action beats → ShotDetail.description / action_beats + image/video/negative prompt→ ShotDetail frame/video prompt fields + camera (CameraSpec) → ShotDetail.camera_shot / angle / movement + dialogue[] → ShotDialogLine[] (index, text, line_mode, speaker/target) + scene_key → ShotDetail.scene_id (via reused Scene) + character_keys / prop_keys / costume_keys → shot-level entity links + +EpisodePackage.characters[]→ Jellyfish Character (project-scoped) + character_key → stable resolution key + display_name → Character.name + description → Character.description + actor_key → Character.actor_id (via Actor below) + costume_key → Character.costume_id (via reused Costume) + +EpisodePackage.assets.actors[] → Jellyfish Actor (reusable visual identity) + actor_key → stable resolution key + display_name / description → Actor.name / Actor.description + +CameraSpec → ShotDetail camera fields + shot_type → ShotDetail.camera_shot (CameraShotType code) + angle → ShotDetail.angle (CameraAngle code) + movement → ShotDetail.movement (CameraMovement code) +``` + +CameraSpec codes are already identical strings to Jellyfish's camera enums (see ADR of +the contract), so the mapping is 1:1 with no translation. Enum decomposition and any +unmappable optional detail follow the Warning Policy (§8). + +## 6. Asset Reuse Policy + +- **Reuse first.** Before creating any Actor / Scene / Prop / Costume / Character, resolve + whether an equivalent already exists (using Jellyfish's existing entity-existence + service semantics) and reuse it. +- **Create only when necessary** — i.e. only when no existing entity matches. +- **Normalization for key comparison**: `trim` → `lowercase` → compare on the resulting + **stable key**. The same normalization is applied to incoming keys and to existing + entity identifiers used for matching. +- **Never duplicate assets inside one transaction**: within a single import, a given + normalized key resolves to exactly one entity; repeated references reuse it rather than + creating duplicates. + +Reuse operates within the target Project scope (Characters are project-scoped; Actors/ +Scenes/Props/Costumes are reusable libraries linked into the Project). + +## 7. Transaction Policy + +- The importer performs **exactly one database transaction**. +- **Failure anywhere → full rollback.** The database is left exactly as before the import. +- **No partial Chapters**: a Chapter is either fully imported (with all its Shots and + links) or not at all. +- **Dry-run never commits.** A dry-run performs validation and mapping and reports what + *would* be created/reused, then rolls back without writing. + +## 8. Warning Policy + +- Unsupported or unmappable **optional** content becomes a **warning**, not a failure. +- Warnings are collected and returned with the import result. +- **Data is never silently discarded**: anything not mapped is surfaced as an explicit + warning so the caller knows what was skipped and why. + +## 9. Error Policy + +- **Errors occur only when the import cannot safely continue** — e.g. contract validation + failure, a required reference that cannot be resolved, or a constraint that would break + Jellyfish integrity. +- **Unsupported optional fields must not become errors** — they are warnings (§8). +- On error, the single transaction rolls back (§7); no partial state remains. + +## 10. Idempotency + +- A repeated import request with the same **Project**, **Episode**, and **IdempotencyKey** + must **not** create duplicate Chapters — the operation is a safe no-op (or returns the + previously-imported result). +- A request that reuses the **same IdempotencyKey** but carries a **changed payload** must + **fail** (conflict), rather than silently creating a divergent or duplicate Chapter. +- Idempotency is keyed on `(Project, Episode, IdempotencyKey)` plus a payload fingerprint + (a canonical SHA-256 of the validated EpisodePackage) to detect changed payloads under a + reused key. + +**Status of this policy vs. its persistence mechanism:** + +- **Idempotency policy — approved** (the three rules above). +- **Persistence mechanism — resolved & approved (Sprint 3).** Repository discovery found + **no** semantically appropriate durable metadata/extension field (`Chapter` has no JSON + column; `Project.stats` is purpose-specific aggregate statistics). Per the approved + decision, a **lightweight import-ledger table** is introduced: + - Model: `app/crypto_animal_studio/domain/import_ledger.py` → `CasImportLedger`. + - Migration: `backend/sql/009-add-cas-import-ledger.sql` (dev `create_all` also registers it). + - Columns: `id, project_id, episode_id, idempotency_key, payload_hash, chapter_id, status, schema_version, created_at, updated_at`. + - Unique constraints: `(project_id, idempotency_key)` and `(project_id, episode_id)`. + - It is a **bookkeeping** table only — not a parallel Project/Shot/Asset system. +- The ledger's full design (schema, foreign keys/deletion behavior, unique constraints, + hashing, replay/conflict, transaction boundaries, failed-import behavior, retention, + rollback migration, and why it is not an Episode domain table) is documented in + **ADR-013 — CAS Import Ledger** (`docs/adr/ADR-013-cas-import-ledger.md`). + +## 11. Explicit Non-Goals + +The importer will **never**: + +- invoke LLMs +- invoke Creative OS +- invoke providers +- invoke Celery +- invoke Redis +- invoke `ScriptDividerAgent` +- invoke `ElementExtractorAgent` +- create parallel Project / Episode / Shot systems +- create a second Provider configuration system + +## 12. Consequences + +**Positive.** +- **CAS stays isolated**: all CAS-specific logic remains in the bounded module; Jellyfish + core models, services, agents, and providers are untouched. +- **Maximum reuse**: the importer writes exclusively through existing Jellyfish studio + services and reuses existing assets/entities, avoiding duplicate systems and data. +- **Deterministic and testable**: because there is no LLM/agent involvement, the importer + is unit-testable with fixtures and produces identical output for identical input. +- **Safe**: single-transaction, all-or-nothing writes plus dry-run mean no partial + Chapters and no destructive surprises; idempotency prevents duplicate imports. +- **Faithful**: authored comedy beats, timing, dialogue alignment, and shot structure are + preserved exactly, because the storyboard is transcribed, not re-inferred. + +**Trade-offs / costs.** +- The importer depends on EpisodePackage being complete and correct; malformed packages + are rejected rather than "repaired" (by design). +- Camera/enum or other optional details that Jellyfish cannot represent surface as + warnings, requiring the caller to review them rather than relying on silent best-effort. +- Idempotency and reuse add resolution logic (normalization, fingerprinting) that must be + carefully tested to avoid false matches. + +These trade-offs are accepted: they are the price of keeping CAS isolated while reusing +the maximum of existing Jellyfish architecture, per the approved final decisions. diff --git a/docs/adr/ADR-013-cas-import-ledger.md b/docs/adr/ADR-013-cas-import-ledger.md new file mode 100644 index 00000000..8b9f6584 --- /dev/null +++ b/docs/adr/ADR-013-cas-import-ledger.md @@ -0,0 +1,159 @@ +# ADR-013 — CAS Import Ledger + +- Status: **Accepted** (approved lightweight infrastructure ledger for durable import idempotency). +- Date: 2026-07-24 +- Related: **ADR-012** (EpisodePackage Importer) — this ADR details the persistence + mechanism referenced by ADR-012 §10. Contract: `docs/crypto-animal-studio/episode-package-v1.md`. +- Scope: one new table `cas_import_ledger`. No other schema changes. + +--- + +## 1. Why existing Project/Chapter metadata fields are unsuitable + +Repository discovery (Sprint 3, Phase 0) looked for a durable, semantically appropriate +place to record idempotency data and found none: + +- **`Chapter`** has no JSON / metadata / extension column at all (fields: `id, project_id, + index, title, summary, raw_text, condensed_text, storyboard_count, status`). Overloading + `title`, `summary`, or `raw_text` (user-facing content) to smuggle idempotency data would + corrupt user content and is explicitly disallowed. +- **`Project.stats`** is a JSON column but is **purpose-specific** — documented as + "聚合统计 (aggregate statistics)" for dashboard rendering. Repurposing a statistics field + to store an import ledger is an unrelated-purpose overload and would collide with its + real use. +- There is no `metadata` / `extra` / `attributes` field anywhere on Project, Chapter, or a + related record; `GenerationTask.payload` belongs to the async task system, not imports. + +Conclusion: there is **no legitimate durable field** to hold `(idempotency_key, +payload_hash, chapter_id)`. Emulating idempotency in process memory was rejected — it is +not durable across restarts or workers and must not be presented as durable. + +## 2. Why a dedicated infrastructure ledger is required + +Idempotency is an **infrastructure/bookkeeping** concern, not episode content. A dedicated +table: + +- gives durable, queryable records that survive restarts and work across processes; +- lets uniqueness be enforced by the **database** (race-safe), not application logic alone; +- keeps CAS content models (Chapter/Shot/…) clean of integration bookkeeping; +- is minimal and additive — it references existing tables and introduces no business logic. + +## 3. Exact table schema + +Table `cas_import_ledger` (MySQL/InnoDB, utf8mb4). ORM: +`app/crypto_animal_studio/domain/import_ledger.py::CasImportLedger`. + +| Column | Type | Null | Default | Notes | +|---|---|---|---|---| +| `id` | VARCHAR(64) | NO | — | Primary key; UUID string generated by the application (`uuid.uuid4()`), matching repo convention (String(64) PKs). | +| `project_id` | VARCHAR(64) | NO | — | FK → `projects.id`. | +| `episode_id` | VARCHAR(255) | NO | — | CAS Episode ID from the EpisodePackage. | +| `idempotency_key` | VARCHAR(255) | NO | — | Caller-supplied key. | +| `payload_hash` | VARCHAR(64) | NO | — | Canonical SHA-256 hex of the validated EpisodePackage. | +| `chapter_id` | VARCHAR(64) | YES | NULL | FK → `chapters.id`; the imported Chapter. | +| `status` | VARCHAR(32) | NO | `'imported'` | Import status. | +| `schema_version` | VARCHAR(16) | NO | `''` | EpisodePackage contract version. | +| `created_at` | DATETIME | NO | `CURRENT_TIMESTAMP` | Repo `TimestampMixin` convention. | +| `updated_at` | DATETIME | NO | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Repo `TimestampMixin` convention. | + +## 4. Foreign keys and deletion behavior + +- `fk_cas_import_project`: `project_id` → `projects.id` **ON DELETE CASCADE** — deleting a + Project removes its import ledger rows (the imports are meaningless without the project). +- `fk_cas_import_chapter`: `chapter_id` → `chapters.id` **ON DELETE SET NULL** — deleting a + Chapter leaves the ledger row (audit trail preserved) but clears the dangling reference. + +## 5. Unique constraints + +- `uq_cas_import_project_key` UNIQUE `(project_id, idempotency_key)` — one result per key + within a project; backs replay/conflict detection and is race-safe. Its `project_id` + left prefix also serves as the index MySQL requires for the project FK. +- `uq_cas_import_project_episode` UNIQUE `(project_id, episode_id)` — a given episode is + imported at most once per project (conservative v1 policy; see §12). +- Secondary non-unique indexes: `ix_cas_import_chapter_id` (for the chapter FK) and + `ix_cas_import_payload_hash` (diagnostics). + +## 6. Canonical payload hashing + +`app/crypto_animal_studio/application/hashing.py::canonical_payload_hash`: + +1. `package.model_dump(mode="json")` → JSON-safe primitive tree (enums → their string values). +2. `json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False)` → stable + key ordering, no incidental whitespace, Unicode preserved → deterministic string. +3. `sha256(utf-8 bytes).hexdigest()` → 64-char hex stored in `payload_hash`. + +Deterministic: identical semantic payloads always hash equal; any field change changes the +hash. + +## 7. Replay and conflict behavior + +Given `(project_id, idempotency_key)`: + +- **Row exists, same `payload_hash`** → **replay**: return the existing `chapter_id`; create + nothing (`status = "replayed"`, `idempotent_replay = true`). +- **Row exists, different `payload_hash`** → **conflict** (`IdempotencyConflictError` → HTTP 409). +- **No row, but `(project_id, episode_id)` already present under another key** → **conflict** + (`EpisodeAlreadyImportedError` → HTTP 409), the conservative v1 policy. +- **No row** → proceed with import and insert one ledger row. + +## 8. Transaction boundaries + +The ledger row is written on the **same `AsyncSession`** as the Chapter, Shots, +ShotDetails, dialogue lines, assets, and links, inside **exactly one transaction** owned by +the request session (`get_db` commits once). The importer only `flush`es (via +`create_and_refresh`); it never commits or opens a second transaction. Idempotency lookups +are reads performed before any writes. The unique constraints provide a race-safe backstop: +a concurrent duplicate fails at `flush` with an integrity error, rolling back the whole +import. + +## 9. Failed-import behavior + +Any failure anywhere in the import — including the **ledger insert itself** — raises, and +the single transaction is rolled back by `get_db`. There is **no partial Chapter, no +orphan ledger row, and no ledger row without its Chapter**. Proven by +`test_ledger_insert_failure_rolls_back_entire_episode` (forces the ledger insert to fail and +asserts every table count is 0). + +## 10. Retention and cleanup policy + +- Ledger rows are **retained for the life of their Project** and are removed automatically + when the Project is deleted (`ON DELETE CASCADE`). +- Rows are **not** auto-expired: they are the durable idempotency record and an audit trail; + expiring them would reintroduce duplicate-import risk. +- If a Chapter is deleted but the Project remains, the row is kept with `chapter_id = NULL` + (audit trail). Re-importing that episode is intentionally still blocked by + `uq_cas_import_project_episode` until an explicit reimport policy exists (§12). +- Volume is bounded (one row per imported episode per project); no scheduled cleanup job is + warranted for v1. + +## 11. Rollback migration + +The repository's migrations are **forward-only** (`backend/sql/00X-*.sql`, no down files). +To roll back this change: + +```sql +-- Manual rollback for 009-add-cas-import-ledger.sql +DROP TABLE IF EXISTS `cas_import_ledger`; +``` + +No other table is altered, so dropping this one table fully reverts the schema change. In +dev (SQLite via `Base.metadata.create_all`), removing the model import from +`app/core/db.py::init_db` and dropping the table (or recreating the dev DB) reverts it. + +## 12. Not an Episode domain table / not a parallel Episode system + +`cas_import_ledger` is **infrastructure bookkeeping**, not a domain entity: + +- It stores no episode *content* (no script, shots, dialogue, or assets) — only identifiers, + a hash, timestamps, and a pointer to the real `Chapter`. +- The episode's actual data lives entirely in Jellyfish's existing `Chapter` / `Shot` / + `ShotDetail` / `ShotDialogLine` / entity tables. The ledger duplicates none of it. +- It therefore does **not** constitute a CAS "Episode table" or a parallel + Project/Episode/Shot/Asset system (which remain prohibited). It is the minimal record + needed to make imports idempotent. + +**`UNIQUE(project_id, episode_id)` is a conservative v1 policy.** It forbids re-importing the +same episode into the same project under a different key. Whether to support an explicit +**reimport / update** operation (e.g. replacing or versioning a previously imported Chapter) +is deliberately **out of scope** and **requires a separate decision/ADR**; v1 fails +conservatively rather than guessing update semantics. diff --git a/docs/architecture-analysis.md b/docs/architecture-analysis.md new file mode 100644 index 00000000..a3341cf5 --- /dev/null +++ b/docs/architecture-analysis.md @@ -0,0 +1,496 @@ +# Jellyfish 架構分析(唯讀) + +> 目標存放位置:`docs/architecture-analysis.md`(Jellyfish 專案根目錄下) +> 來源:`github.com/Forget-C/Jellyfish`(main,commit 3f244c0,release v0.3.2) +> 性質:純架構閱讀分析。**未修改任何程式、未安裝套件、未新增 API、未建立資料庫遷移、未開始實作。** +> 分析對象以 **backend** 為主(Python 49.7%),並涵蓋 frontend / docker。 +> +> **狀態更新(2026-07-23)**:架構評估已被接受。CAS↔Jellyfish 的整合方向已定案為 +> 一組**最終架構決策**(見文末「## Final Architecture Decisions」)。本次僅更新 +> 架構文件以反映這些決策,**不修改任何原始碼**。受決策影響而更新的章節:Overview、 +> Project/Chapter/Shot mapping、Recommended CAS Integration Point、Safe Extension Points、 +> Target data flow、Risks and open decisions。 +> +> **實作進度(Sprint 2 / 2.1)**:已在 `backend/app/crypto_animal_studio/` 建立 +> **受限邊界模組**與 **EpisodePackage v1 契約**(含結構化 `CameraSpec`:shot_type/angle/movement, +> 以 CAS 本地枚舉對齊 Jellyfish `ShotDetail.camera_shot/angle/movement`),並提供 CAS 健康端點 +> `GET /api/v1/crypto-animal-studio/health`。**尚未**做資料庫落地、匯入器、Celery、LLM 或前端。 +> 契約細節見 `docs/crypto-animal-studio/episode-package-v1.md`。 + +--- + +## 0. 總覽 + +Jellyfish 是一個**端到端 AI 短劇(short drama)製作平台**,把「劇本輸入 → 拆鏡 → +實體/資產抽取 → 分鏡準備 → 影像/影片生成 → 匯出」整條產線收斂到單一 workspace。 + +- **Backend**:FastAPI + LangChain/LangGraph + SQLAlchemy(async),`uv` 管理,Python 3.12。 +- **Frontend**:React 18 + Vite + TypeScript + Antd + Zustand(pnpm),OpenAPI 產生 client。 +- **基礎設施**:Docker Compose(MySQL 9 / Redis 7 / RustFS(S3) / backend / celery-worker / front)。 +- **非同步**:Celery + Redis 任務中心(`GenerationTask` 為持久真相來源)。 +- **AI 能力**:一組窄職責 LangChain Agent(拆鏡、抽取、一致性檢查、資訊分析…)。 +- **授權**:Apache-2.0。 + +核心領域模型(**沒有 "Episode" 這個實體**,最接近的是 Chapter): + +``` +Project ─┬─ Chapter ─── Shot ─── ShotDetail ─┬─ ShotDialogLine + │ │ └─ ShotFrameImage + │ ├─ ShotCharacterLink + │ ├─ ShotExtractedCandidate(角色/場景/道具/服裝候選) + │ └─ ShotExtractedDialogueCandidate(對白候選) + ├─ Character ── Actor / Costume / (CharacterPropLink → Prop) + └─ Project{Actor,Scene,Prop,Costume}Link(資產在 project/chapter/shot 層的掛載) +Scene / Prop / Costume / Actor 為「跨專案可重用」資產庫;Character 為「專案內」角色。 +GenerationTask ── GenerationTaskLink ── FileItem ── FileUsage(檔案在業務鏈上的用途) +Provider ── Model ── ModelSettings(多供應商/模型與預設) +PromptTemplate(提示詞模板,分類 PromptCategory) +``` + +### CAS ↔ Jellyfish 對映(最終決策) + +Jellyfish 本身**沒有 "Episode" 實體**。整合方向已定案如下(詳見文末 ADR 表): + +- **一個 CAS Episode = 一個 Jellyfish Chapter。** +- **一個 Jellyfish Project = 一個「系列 / 製作 / 季(series / production / season)」**, + 可包含**多個** CAS Chapter/Episode。 +- CAS 會把它**已完成的 storyboard 直接映射**成 Jellyfish 的 + `Chapter → Shot → ShotDetail → ShotDialogLine` 以及 Character/資產連結; + **不得**把已完成的 storyboard 再送回 `ScriptDividerAgent` + (否則會破壞喜劇節拍 comedy beats、時間 timing、對白對位 dialogue alignment 與鏡頭結構)。 +- `Chapter.raw_text` 仍保存**完整生成的劇集劇本**以利追溯(traceability), + 但 **Shots 一律由 EpisodePackage 建立**,而非由 raw_text 重新拆鏡。 +- CAS 不得建立與 Jellyfish 平行重複的 Project/Episode/Shot/Asset/Media/Prompt/Task 系統; + 一律**重用** Jellyfish 既有系統。 + +--- + +## 1. Backend 架構 + +分層清楚(README 明訂「路由瘦身、邏輯下沉」): + +| 層 | 路徑 | 職責 | +|---|---|---| +| 入口 | `app/main.py` | FastAPI app、統一例外處理(全部包成 `ApiResponse`)、CORS、`lifespan` 啟動時 `bootstrap_all_registries()`、掛載 `/api/v1`、`/health`。 | +| 設定 | `app/config.py` | `pydantic-settings`,從 `.env`/環境變數載入;DB URL、Redis/Celery、CORS、S3。**無硬編碼密鑰**。 | +| 啟動註冊 | `app/bootstrap.py` | 依序註冊「供應商能力」→「任務執行器」(皆冪等)。 | +| 依賴注入 | `app/dependencies.py` | `get_db`(async session)、`get_llm`/`get_nothinking_llm`(由 DB 內 Provider/Model 動態建構 ChatOpenAI)。 | +| 核心 | `app/core/` | DB 引擎、Celery、S3 storage、task_manager(可插拔 store/strategy)、tasks(image/video 執行器 registry)、contracts、integrations(openai / volcengine)。 | +| 資料 | `app/models/` | SQLAlchemy ORM(27 張表)。 | +| Schema | `app/schemas/` | Pydantic 請求/回應 + `skills/`(AI 技能輸出結構)。 | +| 業務 | `app/services/` | `common` / `studio` / `film` / `llm` / `worker` / script_processing。 | +| AI | `app/chains/agents/` | LangChain Agent(PromptTemplate + 結構化輸出解析)。 | +| 路由 | `app/api/v1/routes/` | film / llm / studio / script_processing / health。 | +| Celery | `app/tasks/execute_task.py` | 統一 Celery 入口:只收 `task_id` → 依 `task_kind` 從 registry 找 executor 執行。 | + +要點: +- **例外統一**:`main.py` 把 HTTPException/ValidationError/未捕捉例外全轉成 `{code,message,data:null}`。 +- **同步/非同步雙軌**:FastAPI 用 async session(`core/db.py`);Celery worker 用 sync session(`core/db_sync.py`),且 prefork 子進程會 `reset_db_runtime()` 重建 engine,避免事件迴圈綁定錯亂。 +- **註冊表模式**:provider 能力與 task adapter 都走「bootstrap 冪等註冊 + registry 解析」,擴充友善。 + +--- + +## 2. Frontend 架構 + +- `front/`:React 18 + Vite 5 + TypeScript 5 + **Antd 5** + **Zustand**(狀態)+ react-router 6 + i18next(en-US / zh-CN)。 +- 主要頁面在 `front/src/pages/aiStudio/`,子模組對齊後端領域:`project / chapter / shots / assets / agents / models / prompts / files / editor / components / hooks`。 +- **型別/請求由後端 OpenAPI 產生**:`front/src/services/generated/`(`core/`、`models/`、`services/`)由 `openapi-typescript-codegen` 產出。 +- 產生流程(`package.json` scripts): + - `openapi:fetch` → `curl http://127.0.0.1:8000/openapi.json -o front/openapi.json` + - `openapi:gen` → 產生 `src/services/generated` + - `openapi:update` → 兩者合一。 +- 有 `mocks/`(msw)供前端獨立開發。 + +含義:**前端是後端契約的下游**。任何後端 API/schema 變更,前端只要重跑 `openapi:update` 即可同步型別——這對整合很關鍵(見 §16)。 + +--- + +## 3. Docker / 部署 + +`deploy/compose/docker-compose.yml` 定義 7 個 service: + +| service | 說明 | +|---|---| +| `mysql` (9.0) | 主資料庫,帶 healthcheck,資料卷 `mysql_data`。 | +| `redis` (7) | Celery broker。 | +| `rustfs` | S3 相容物件儲存(素材檔案),暴露 9000/9001。 | +| `backend-init-db` | 一次性:`uv run python init_db.py` 建 27 張表。 | +| `mysql-init-sql` | 一次性:依序套用 `backend/sql/*.sql` 遷移。 | +| `backend` | `uvicorn app.main:app`,port 8000。 | +| `celery-worker` | `celery -A app.core.celery_app:celery_app worker`。 | +| `front` | Nginx 靜態站,port **7788**。 | + +- Dockerfile:`deploy/docker/backend.Dockerfile`、`front.Dockerfile`、`nginx.conf`、entrypoint 產生 `env.js`。 +- **本機開發可免 Docker**:後端預設 `DATABASE_URL=sqlite+aiosqlite:///./jellyfish.db`,首次存取自動建檔;Celery/Redis/S3 皆為選配(未配置時對應功能降級或 503)。 + +--- + +## 4. Database + +- ORM 優先:`init_db.py` 匯入所有模型後 `Base.metadata.create_all()`(開發/首建)。 +- **正式遷移走 `backend/sql/*.sql`**(依檔名排序套用): + `001-init-prompt-template` → `002-add-shot-extracted-candidates` → + `003-normalize-shot-status-remove-generating` → `004-add-generation-task-cancel-fields` → + `005-add-provider-category-base-urls` → `006-migrate-model-defaults-to-model-settings-and-drop-is-default` → + `007-add-video-size-ratio-defaults-and-overrides` → `008-add-shot-action-beats`。 +- 引擎抽象於 `settings.database_url`:SQLite(aiosqlite)/ MySQL(aiomysql)/ PostgreSQL(asyncpg)皆可。 +- 共 **27 張業務表**(models `__init__` 匯出清單為準)。 + +**注意**:ORM `create_all` 與手寫 SQL 遷移**並存**。兩者需保持一致;新增欄位時「改 ORM 模型」與「補一支 `009-*.sql`」要同時做,否則 Docker(走 SQL)與本機(走 create_all)會漂移。 + +--- + +## 5. ORM(SQLAlchemy 2.0 async) + +- Base:`app/core/db.py::Base`(DeclarativeBase);`TimestampMixin`(created_at/updated_at)。 +- 模型依類別拆檔,`app/models/studio.py` 聚合 re-export: + - `studio_projects.py`:**Project、Chapter**、Project{Actor,Scene,Prop,Costume}Link。 + - `studio_shots.py`:**Shot、ShotDetail、ShotDialogLine、ShotFrameImage、ShotCharacterLink、ShotExtractedCandidate、ShotExtractedDialogueCandidate**。 + - `studio_assets.py`:**Scene、Prop、Costume、Actor、Character、CharacterPropLink**。 + - `studio_asset_images.py`:各資產的多視角圖(AssetViewAngle/QualityLevel)。 + - `studio_prompts_files_timeline.py`:**PromptTemplate、FileItem、TimelineClip**。 + - `studio_file_usages.py`:**FileUsage**(file × project/chapter/shot × usage_kind)。 + - `llm.py`:**Provider、Model、ModelSettings**(+ AgentTypeKey/ProviderStatus/ModelCategoryKey enum)。 + - `task.py`:**GenerationTask**;`task_links.py`:**GenerationTaskLink**。 +- 列舉集中在 `models/types.py`(ProjectStyle、ProjectVisualStyle、ShotStatus、CameraShotType/Angle/Movement、DialogueLineMode、VFXType、PromptCategory、FileUsageKind…)。 + +關鍵欄位觀察: +- `Chapter.raw_text` / `condensed_text`:**劇本原文與模型精簡後版本**——這是拆鏡與抽取的輸入源。 +- `Shot.script_excerpt` + `ShotDetail`(camera_shot/angle/movement、duration、mood_tags、vfx、`action_beats`、`first/last/key_frame_prompt`)。 +- `Character` 為 **project 內**,關聯一個 `Actor`(視覺身份/選角)與可選 `Costume`。 +- 資產(Scene/Prop/Costume/Actor)為**跨專案可重用庫**,透過 `Project*Link` 掛到 project/chapter/shot。 + +--- + +## 6. API + +- 統一前綴 `/api/v1`,回應殼 `ApiResponse{code,message,data,meta}`(`schemas/common.py`:`success_response/created_response/empty_response/paginated_response`)。 +- 路由聚合(`api/v1/__init__.py`):`health`、`/film`、`/llm`、`/studio`、`script_processing`。 +- Studio 子路由(`routes/studio/__init__.py`):`projects、chapters、shots(+shot-details/dialog-lines/links/frame-images)、entities、prompts、files、timeline、image-tasks、shot-character-links`。 +- Film 子路由:`generated_video、tasks_images、task_status`(另有 `film/extract/*` 影視技能)。 +- `script_processing`:分鏡、實體/對白抽取、合併、變體分析、一致性檢查、劇本優化/精簡——**皆為 async 技能任務**(建立 task → Celery → 輪詢)。 +- 錯誤語彙統一(`services/common/errors.py`):`entity_not_found / entity_already_exists / required_field / invalid_choice / not_belong_to`。 + +--- + +## 7. Task Queue(任務佇列) + +雙層設計: + +1. **應用層 TaskManager**(`core/task_manager/`):`store`(記憶體或 DB)+ `strategy`(`streaming` / `async_polling`)可插拔;`TaskStatus`(pending/running/streaming/succeeded/failed/cancelled)、`TaskRecord`/`TaskStatusView`/`TaskListItemView`(任務中心列表、可回跳 project/chapter/shot)。 +2. **執行層 Celery**(`core/celery_app.py` + `app/tasks/execute_task.py`):Celery 只收 `task_id`;`run_task_celery` 依 `GenerationTask.task_kind` 從 `services/worker/task_registry` 解析 executor 執行;支援 `revoke`(取消)。 +- **執行器 registry**(`core/tasks/registry.py` + `bootstrap.py`):以 `(task_kind, provider_key)` 為鍵註冊 factory;內建 `image_generation/video_generation × openai/volcengine` 四種。 +- 持久真相是 **`GenerationTask`** 表(progress、result、error、cancel_*、executor_*);`GenerationTaskLink` 把任務結果連回資源與 `FileItem`。 + +**擴充點**:新增一種任務=加 `task_kind` + 實作 executor + `register_task_adapter` 註冊;不需改 Celery 入口。 + +--- + +## 8. Prompt 管理 + +兩套機制並存: + +- **資料庫模板 `PromptTemplate`**:`category`(`PromptCategory`:frame_head/tail/key image/prompt、video_prompt、storyboard_prompt、各資產 front/other 圖、bgm/sfx…)、`content`、`variables`、`is_default`、`is_system`(系統預置僅初始化寫入、接口禁止刪改)。經 `routes/studio/prompts.py` 管理;`sql/001` 初始化。 +- **程式內 Agent PromptTemplate**:各 Agent 於 `app/chains/agents/*` 內以 LangChain `PromptTemplate` 固化 system+task(例如 `ScriptDividerAgent` 的分鏡提示詞)。 + +前者是「使用者可調的生成模板」,後者是「AI 技能的內建流程提示詞」。 + +--- + +## 9. Asset(資產)管理 + +- 資產類型:**Scene / Prop / Costume / Actor**(跨專案庫)+ **Character**(專案內角色)。 +- 每類資產有多視角圖(`*Image` 表,`AssetViewAngle` front/left/right/back/three_quarter/top/detail;`AssetQualityLevel` low→ultra)。 +- 掛載關係:`Project{Actor,Scene,Prop,Costume}Link` 可綁在 project / chapter / shot 任一層 → 支援跨鏡頭重用與一致性。 +- 一致性是「一等公民」:`ShotExtractedCandidate` 讓抽取到的資產先進候選、由人確認 `linked/ignored` 再落地;`consistency_checker_agent` 檢查漂移。 +- 服務:`services/studio/entities.py`、`entity_crud.py`、`entity_images.py`、`entity_existence.py`(檢查重名鼓勵重用)、`shot_assets*.py`。 +- 檔案:`FileItem`(type image/video、`storage_key` 指向 S3)+ `FileUsage`(用途歸屬)+ `core/storage.py`(boto3 + anyio 執行緒池,避免阻塞事件迴圈)。 + +--- + +## 10. Character 管理 + +- `Character`(`studio_assets.py`):project 內,`name/description/style/visual_style`,關聯 `actor_id`(必填,視覺身份/選角)+ 可選 `costume_id`;關聯 `CharacterPropLink`(隨身道具)、`ShotCharacterLink`(鏡頭出演)、`CharacterImage`(角色圖)。 +- 相關 Agent:`character_portrait_analysis_agent`(角色肖像分析)。 +- 服務/路由:`shot_character_links`(鏡頭↔角色)、`entities`(CRUD)。 +- 設計語意:**Character = 敘事角色**,**Actor = 可重用的視覺演員/選角**,兩者分離讓「同一演員演不同角色 / 同一角色換裝」成為可能。 + +--- + +## 11. Project / Shot /(Episode)關聯 + +- 層級:`Project → Chapter → Shot → ShotDetail(→ ShotDialogLine / ShotFrameImage)`。 +- **Jellyfish 沒有 "Episode" 實體**。**最終對映(已定案)**: + - **CAS Episode → Jellyfish Chapter**(章節帶 `raw_text`/`condensed_text` 劇本、`storyboard_count`、`status`)。 + - **Jellyfish Project → 系列 / 製作 / 季(series / production / season)**,一個 Project 可含多個 CAS Chapter/Episode。 + - CAS 的 storyboard **直接**建立 `Chapter / Shot / ShotDetail / ShotDialogLine / Character 與資產連結`; + **不經過** `ScriptDividerAgent`(保護 comedy beats/timing/dialogue alignment/shot structure)。 + - `Chapter.raw_text` 保存**完整生成劇本**供追溯;但 **Shots 由 EpisodePackage 建立**,不由 raw_text 重新拆鏡。 +- `Shot`:`chapter_id`、`index`(章節內唯一)、`title`、`script_excerpt`、`status`(pending/generating/ready)、`generated_video_file_id`。 +- `ShotDetail`:鏡頭語意(景別/角度/運鏡/時長/情緒/VFX/action_beats)+ 三種 frame prompt(首/尾/關鍵幀)。CAS storyboard 的鏡頭資訊直接填入此表,跳過抽取候選流程。 +- Shot 的準備狀態機(`services/studio/shot_preparation_state.py`、`shot_status.py`、`shot_video_readiness.py`):抽取候選 → 確認/連結 → `ready` → 進生成 workspace。**CAS 匯入的 Shot 因已由 storyboard 直接建立,可設定 `skip_extraction` 直接進入 ready 判定**(不重跑抽取)。 + +--- + +## 12. Provider 架構 + +**兩條 provider 軸線,勿混淆:** + +1. **文字/LLM provider(DB 驅動)**:`Provider`/`Model`/`ModelSettings` 存於 DB;`services/llm/resolver.py` 依 `ModelSettings` 的 `default_text/image/video_model_id` 解析出 Provider→建構 `ChatOpenAI`(`dependencies.get_llm`)。內建 provider spec(`provider_bootstrap.py`):**openai(text/image/video)、volcengine(image/video)、aliyun_bailian(text)**,且各類別可有獨立 base_url(`sql/005`)。 +2. **影像/影片生成 provider(契約驅動)**:`core/contracts/provider.py`(`ProviderKey = openai | volcengine`)+ `core/integrations/openai/*`、`volcengine/*`(images/video/capabilities/payload)+ task adapter registry(§7)。 + +要點:供應商 API Key 存 DB `Provider.api_key`(敏感欄位),非硬編碼;文字模型走 LangChain `ChatOpenAI`(OpenAI 相容)。 + +--- + +## 13. 生成流程(Generation Flow) + +端到端(對照 README 與程式): + +``` +劇本(Chapter.raw_text) + → ScriptSimplifier/Optimizer(精簡/優化,可選) + → ScriptDivider(拆鏡)→ 寫入 Shots(services/studio/script_division.py) + → ElementExtractor(抽取角色/場景/道具/服裝 + 對白)→ ShotExtractedCandidate / DialogueCandidate + → 人工確認/連結資產(entities、shot-character-links)→ Shot 準備狀態 + → EntityMerger / ConsistencyChecker / *InfoAnalysis(合併、去重、一致性、資產細節) + → 建 frame prompt(shot_frame_prompt_agents / services/studio/generation/frame/*) + → 影像生成任務(image_generation)→ ShotFrameImage / FileItem + → 影片生成任務(video_generation)→ generated_video / FileItem + → Timeline / 匯出 +``` + +- AI 步驟多為**非同步任務**(建立 `GenerationTask` → Celery executor → 輪詢/連結)。 +- 生成子系統模組化:`services/studio/generation/{asset_image,frame,video}/{build_base,build_context,build_submission,derive_preview}.py` — 每種生成都走「建基礎 → 建上下文 → 建提交 → 產預覽」四步,擴充一致。 + +### Target data flow(CAS,最終決策) + +CAS 的產物是**已完成的 storyboard/EpisodePackage**,因此走一條**不同於原生拆鏡**的匯入路徑, +**刻意繞過 `ScriptDividerAgent` 與抽取候選流程**,以保護喜劇節拍與鏡頭結構: + +``` +Sample EpisodePackage(CAS 產出:episode + storyboard + dialogue + characters) + → validation(schema 驗證,缺欄/格式錯即失敗,不靜默) + → synchronous import service(第一里程碑:同步,不使用 Celery) + ├─ 建/取 Project(= series/season;可含多集) + ├─ 建 Chapter(raw_text = 完整生成劇本,供追溯) + ├─ 由 storyboard 直接建 Shot / ShotDetail / ShotDialogLine + ├─ 建立 Character 與資產連結(重用既有 entities / links) + └─ (不經 ScriptDivider、不經 ElementExtractor 候選確認) + → Chapter 與 Shots 在 Jellyfish(/docs、前端)可見 ✅(第一里程碑驗收點) + →(後續里程碑)接回原生 frame/image/video 生成與 Timeline/匯出 +``` + +第一里程碑刻意**同步且無 Celery**:`EpisodePackage → validation → 同步匯入 service → Chapter+Shots 可見`。 +非同步任務化留待後續里程碑,屆時再沿用既有任務中心(不另建 CAS 任務系統)。 + +--- + +## 14. AI Agent 支援能力 + +`app/chains/agents/`,全部繼承 `AgentBase[T]`(`base.py`):固化 `system_prompt` + `PromptTemplate` + `output_model`(Pydantic),呼叫 LLM 後有**非常強韌的 JSON 兜底解析**(剝 markdown、補未加引號的 key、修尾逗號、Python literal 兜底、`Foo(a=1)` kwargs 解析…)。 + +內建 Agent: +- `ScriptDividerAgent`(拆鏡)、`ScriptOptimizerAgent`、`ScriptSimplifierAgent` +- `ElementExtractorAgent`(角色/場景/道具/服裝/對白抽取)、`EntityMergerAgent`(實體合併) +- `ConsistencyCheckerAgent`(一致性)、`VariantAnalyzerAgent`(變體) +- `CharacterPortraitAnalysisAgent`、`SceneInfoAnalysisAgent`、`PropInfoAnalysisAgent`、`CostumeInfoAnalysisAgent` +- `ShotFramePromptAgents`(分鏡幀提示詞) + +輸出結構定義於 `app/schemas/skills/*`(如 `ScriptDivisionResult`)。這是一套「窄職責、結構化輸出、可測試」的 agent 架構,與 LangGraph 相容(依賴含 `langgraph`)。 + +--- + +## 15. OpenAPI + +- FastAPI 內建 `/openapi.json`、`/docs`(Swagger)、`/redoc`。 +- **前端型別/請求由 OpenAPI 產生**(`front` 的 `openapi:update`)。因此 OpenAPI 是「後端↔前端」的正式契約:新增/改動路由或 Pydantic schema,前端重生成即可同步。 +- 後端測試中有 `test_api_response_envelopes.py` 等,確保回應殼一致——OpenAPI 契約穩定性受測試保護。 + +--- + +## 16. 專案資料夾說明(Folder Guide) + +``` +Jellyfish/ +├── backend/ # FastAPI 後端(整合主要落點) +│ ├── app/ +│ │ ├── main.py # 入口、例外殼、CORS、lifespan +│ │ ├── config.py # pydantic-settings(.env) +│ │ ├── bootstrap.py # 啟動註冊(provider / task adapter) +│ │ ├── dependencies.py # get_db / get_llm +│ │ ├── core/ # db、celery、storage、task_manager、tasks、contracts、integrations +│ │ ├── models/ # ORM(Project/Chapter/Shot/資產/task/llm) +│ │ ├── schemas/ # Pydantic 請求回應 + skills/(AI 輸出) +│ │ ├── services/ # common/studio/film/llm/worker + script_processing +│ │ ├── chains/agents/ # LangChain Agents +│ │ ├── api/v1/routes/ # film/llm/studio/script_processing/health +│ │ └── tasks/execute_task.py# Celery 統一入口 +│ ├── sql/ # DB 遷移(001–008) +│ ├── tests/ # pytest(service/api/agent) +│ ├── init_db.py / init_storage.py +│ └── pyproject.toml(uv) +├── front/ # React+Vite 前端;services/generated 由 OpenAPI 產生 +├── deploy/ # docker compose + Dockerfile + nginx +├── docs/ # 專案文件(本檔目標位置) +├── site/ # 專案網站 +├── AGENTS.md / conftest.py / pytest.ini +``` + +--- + +## 17. 模組說明(Module Guide,速查) + +| 模組 | 一句話 | 穩定性 | +|---|---|---| +| `core/db*`, `models/*`, `sql/*` | 資料真相與遷移 | 核心,改動需謹慎 + 補遷移 | +| `core/task_manager`, `core/tasks`, `tasks/execute_task` | 任務中心與 Celery | 核心,走 registry 擴充 | +| `core/integrations/{openai,volcengine}` | 生成供應商實作 | 以「新增資料夾」方式擴充 | +| `services/llm/*` | Provider/Model/預設解析 | 相對穩定;擴 provider 走 `provider_bootstrap` | +| `services/studio/*` | 專案/章節/鏡頭/資產/檔案主業務 | 活躍;整合多在此加 service | +| `services/script_processing*`, `chains/agents/*` | AI 技能與 agent | 擴充友善(新增 agent + schema + task_kind) | +| `api/v1/routes/*` | HTTP 介面(薄) | 新增子路由即可 | +| `schemas/*` | 契約(影響 OpenAPI/前端) | 只加不改,向後相容 | +| `front/services/generated` | 由 OpenAPI 自動產生 | **不要手改**,重生成 | + +--- + +## 18. 哪些模組適合擴充(建議的 Extension Points) + +1. **AI 技能層 `chains/agents/` + `schemas/skills/`**:新增 Agent(固定 system+template+output_model),最符合現有模式。 +2. **任務種類 `task_kind` + `services/worker` / `core/tasks/registry`**:新增一種生成/處理任務,只需註冊 executor。 +3. **Provider 能力 `services/llm/provider_bootstrap` + `core/integrations//`**:接新的文字/影像/影片供應商。 +4. **Studio service `services/studio/*` + `api/v1/routes/studio/*`**:新增業務動作(薄路由 + 下沉邏輯 + `ApiResponse`)。 +5. **Prompt 模板 `PromptTemplate`(DB)**:以資料方式擴充生成模板(`is_system` 保護預置)。 +6. **生成四步 `services/studio/generation/*`**:沿 `build_base→context→submission→derive_preview` 擴新的生成型態。 + +擴充守則:路由薄、邏輯進 service、輸出用 `ApiResponse`、schema 只加不改、DB 改動同時補 ORM 與 `sql/00X`、跑 `uv run pytest` 與 `pylint`。 + +### CAS 的擴充邊界(最終決策) + +CAS 必須是一個**清楚界定、bounded 的模組**,不得把商業邏輯散落在 Jellyfish 各處。 +建議採用與現有 repo 相容的結構(實際路徑可依 Jellyfish 慣例微調): + +``` +backend/app/crypto_animal_studio/ + api/ # 對外路由(薄,回 ApiResponse) + application/ # 用例/流程編排(含同步匯入 service) + domain/ # CAS 領域模型與規則 + schemas/ # EpisodePackage 等 Pydantic 契約 + agents/ # CAS 專屬 agent(若需要) + integrations/ # 與 Jellyfish Provider/Model/ModelSettings 的橋接 + tests/ # 模組自帶測試 +``` + +CAS 擴充的硬性約束: +- **重用,不重複**:不得建立平行的 CAS Project/Episode/Shot/Asset/Media/Prompt/Task 系統; + 一律呼叫 Jellyfish 既有 `services/studio/*`、任務中心、檔案/儲存、PromptTemplate。 +- **Provider 收斂**:CAS 最終**必須**使用 Jellyfish 的 `Provider / Model / ModelSettings`。 + 過渡期可用**臨時 adapter**,但**不得**遷移或保留第二套獨立的 provider 設定系統。 +- **不新增 enum**:初期整合**不新增** `ProjectStyle` 或 `ProjectVisualStyle` 列舉值(沿用既有值)。 +- **第一里程碑同步**:先做 `EpisodePackage → validation → 同步匯入 → Chapter+Shots 可見`, + **暫不實作 Celery 任務**。 +- **不改契約根基**:不動 `ApiResponse`、既有 `sql/00X` 遷移、`front/services/generated`、既有 enum 值。 + +--- + +## 19. 哪些模組不應修改(Do-Not-Modify / 高風險) + +1. **`core/db.py` 的 async/prefork 機制與 `reset_db_runtime`**:牽動 Celery 事件迴圈正確性。 +2. **`main.py` 例外處理與 `ApiResponse` 殼**:全域契約,改了會波及所有端點與前端。 +3. **`schemas/common.py`(ApiResponse/分頁)**:契約根基。 +4. **`front/src/services/generated/`**:自動產生物,手改會被覆蓋。 +5. **`sql/001–008` 既有遷移**:只能往後加 `009+`,不可回改。 +6. **`PromptTemplate.is_system` 的系統預置**:接口層明訂禁止刪改。 +7. **`models/types.py` 既有 enum 值**:可新增成員,不可改/刪既有值(DB 已存字面值)。 + **但初期 CAS 整合連「新增」都不做**——不新增 `ProjectStyle` / `ProjectVisualStyle`。 +8. **task registry key 語意 `(task_kind, provider_key)`**:衝突會在啟動註冊時報錯。 +9. **不得建立 CAS 平行系統**:Project/Episode/Shot/Asset/Media/Prompt/Task 一律重用 Jellyfish 既有系統。 +10. **不得保留第二套 provider 設定系統**:CAS 收斂到 Jellyfish `Provider/Model/ModelSettings`(過渡期僅允許臨時 adapter)。 + +--- + +## 20. 建議的 CAS(Crypto Animal Studio)Integration Point + +> 反映最終決策;本文件僅描述切入點,**不含實作**。 + +CAS 產物是「news → 劇集 + 對白 + **已完成 storyboard** + 角色設定」(EpisodePackage)。整合切入點已定案: + +1. **Bounded 模組(唯一落點)** + 全部 CAS 邏輯收斂在一個界定清楚的模組(路徑可依 Jellyfish 慣例微調): + `backend/app/crypto_animal_studio/{api,application,domain,schemas,agents,integrations,tests}`。 + **不得**把邏輯散落到 `services/studio/*` 各處。 + +2. **主切入點:EpisodePackage → 同步匯入 service(第一里程碑)** + `Sample EpisodePackage → validation → synchronous import service → Chapter + Shots 可見`。 + 匯入 service 建/取 **Project(= series/season)** → 建 **Chapter**(`raw_text` = 完整劇本,追溯用) + → 由 storyboard **直接**建 `Shot / ShotDetail / ShotDialogLine`。**第一里程碑不使用 Celery。** + +3. **直接映射 storyboard,禁止回送拆鏡** + Shots 一律由 **EpisodePackage** 建立;**不得**把已完成 storyboard 再送回 `ScriptDividerAgent` + (保護 comedy beats / timing / dialogue alignment / shot structure)。匯入的 Shot 可 `skip_extraction`。 + +4. **角色與資產:重用既有系統** + Bull/Bear/Fox/Hammy/Monkey/Walter → 既有 `Character`(各綁 `Actor` 作視覺身份)+ 資產連結, + 呼叫既有 `services/studio/entities.py` 等。**不建立平行的 CAS 角色/資產系統。** + +5. **對外介面:一條薄路由(回 `ApiResponse`)** + 由 `crypto_animal_studio/api/` 掛一條匯入端點(例:`POST /api/v1/crypto-animal/import`)。 + 前端重跑 `openapi:update` 取得型別。**不新增平行 Task 系統**;後續要非同步再沿用既有任務中心。 + +6. **Provider:收斂到 Jellyfish 治理** + CAS 最終**必須**使用 Jellyfish `Provider / Model / ModelSettings`。過渡期允許**臨時 adapter** + (放 `crypto_animal_studio/integrations/`),但**不得**保留第二套獨立 provider 設定系統。 + +7. **不新增 enum(初期)** + 初期整合沿用既有 `ProjectStyle` / `ProjectVisualStyle` 值,**不新增**列舉、**不建**資料庫遷移。 + +--- + +## 21. 附錄:關鍵事實速記 + +- Python 3.12;`uv` 管理;FastAPI + LangChain/LangGraph + SQLAlchemy async。 +- DB 預設 SQLite,可切 MySQL/PostgreSQL;27 張表;遷移 `sql/001–008`。 +- 回應殼 `ApiResponse{code,message,data,meta}`;錯誤語彙統一。 +- 任務:TaskManager(可插拔)+ Celery(`task_kind`→registry→executor)+ `GenerationTask` 持久。 +- 生成供應商:openai / volcengine(image+video)、aliyun_bailian(text)。 +- 前端契約由 OpenAPI 產生,`generated/` 勿手改。 +- 一致性/資產重用是核心設計;Character(敘事) 與 Actor(視覺) 分離。 +- 本文件為架構文件;本次僅更新文件以反映最終決策,**未變更任何原始碼、未安裝套件、未建立資料庫遷移**。 + +--- + +## 22. Risks and open decisions(風險與待決事項) + +反映最終決策後,仍待處理或需持續留意的事項: + +| # | 項目 | 現況 / 決策 | 待決或風險 | +|---|---|---|---| +| R1 | CAS 授權 | **尚未正式確定**(先前「Proprietary」之敘述已移除,因無依據) | vendored / 對外發佈前需正式確立授權;影響能否併入 Apache-2.0 repo。 | +| R2 | Provider 收斂 | 最終走 Jellyfish `Provider/Model/ModelSettings`;過渡允許臨時 adapter | 需訂「臨時 adapter 退場時程」,避免第二套設定長存。 | +| R3 | 系列/季粒度 | Project = series/season,含多個 Chapter/Episode | 需定義 Project 建立/選取規則(何時新開 Project vs 沿用)。 | +| R4 | ShotDetail 欄位覆蓋 | storyboard 直接填 camera/duration/dialogue | CAS storyboard 未必涵蓋所有 ShotDetail 欄位;缺項的預設策略待定。 | +| R5 | raw_text 與 Shots 一致性 | raw_text 保存完整劇本、Shots 由 EpisodePackage 建立 | 兩者為不同來源,需避免被誤解為「raw_text 會被重新拆鏡」。 | +| R6 | 角色/資產去重 | 重用既有 `Character`/`Actor`/資產 | 跨集/跨 Project 的實體重用與命名一致性策略待定。 | +| R7 | 非同步化時機 | 第一里程碑同步、無 Celery | 何時、以何準則升級為既有任務中心的非同步任務待定。 | +| R8 | enum 缺口 | 初期不新增 `ProjectStyle`/`ProjectVisualStyle` | 若未來確需喜劇/動漫專屬 style,屬**後續**決策(新增成員 + `sql/009`)。 | + +--- + +## Final Architecture Decisions + +以下為已定案(Final)之架構決策,採 ADR 風格記錄。除非另立新 ADR,否則後續實作須遵循。 + +| # | Decision(決策) | Status | Reason(理由) | Consequences(後果/影響) | +|---|---|---|---|---| +| ADR-1 | 一個 CAS Episode 對映一個 Jellyfish **Chapter** | Final | Chapter 已是章節級劇本+分鏡容器,語意最貼近「一集」 | 匯入以 Chapter 為單位;不需新增 Episode 實體。 | +| ADR-2 | Jellyfish **Project = 系列/製作/季**,可含多個 CAS Chapter/Episode | Final | 對齊影視「一部作品含多集」的結構 | 需定義 Project 建立/選取規則(見 R3);資產在 Project 層跨集重用。 | +| ADR-3 | CAS 將 storyboard **直接**映射為 Chapter/Shot/ShotDetail/ShotDialogLine + 角色/資產連結 | Final | 保留 CAS 既完成之鏡頭與對白結構,最高保真 | 匯入器負責建這些列;跳過抽取候選;Shot 可 `skip_extraction`。 | +| ADR-4 | **不得**把已完成 storyboard 回送 `ScriptDividerAgent` | Final | 重新拆鏡會破壞 comedy beats、timing、dialogue alignment、shot structure | CAS 匯入路徑刻意繞過拆鏡與 ElementExtractor 候選流程。 | +| ADR-5 | `Chapter.raw_text` 保存**完整生成劇本**供追溯;**Shots 由 EpisodePackage 建立** | Final | 兼顧可追溯性與鏡頭保真 | raw_text 與 Shots 為不同來源;raw_text 不被重新拆鏡。 | +| ADR-6 | **不建立**平行的 CAS Project/Episode/Shot/Asset/Media/Prompt/Task 系統 | Final | 避免雙軌資料與維運分裂 | 一律重用 Jellyfish 既有 `services/studio/*`、任務中心、儲存、PromptTemplate。 | +| ADR-7 | CAS 為**界定清楚的 bounded 模組**(`backend/app/crypto_animal_studio/{api,application,domain,schemas,agents,integrations,tests}`;路徑可依慣例微調) | Final | 防止商業邏輯散落,利於維護與測試 | 所有 CAS 邏輯集中於此模組;對外只經薄 api 層。 | +| ADR-8 | CAS 最終使用 Jellyfish **Provider/Model/ModelSettings**;過渡期允許臨時 adapter | Final | 統一模型治理,避免密鑰/設定分裂 | 不得遷移或保留第二套 provider 設定系統;adapter 需有退場計畫(R2)。 | +| ADR-9 | **第一里程碑不實作 Celery**:Sample EpisodePackage → validation → 同步匯入 service → Chapter+Shots 可見 | Final | 先以最小、可驗收的同步路徑降風險 | 非同步任務化延後;驗收點為 Jellyfish 內可見 Chapter 與 Shots。 | +| ADR-10 | 初期整合**不新增** `ProjectStyle` / `ProjectVisualStyle` enum | Final | 降低 schema/遷移面積,先跑通主路徑 | 沿用既有列舉值;style 擴充屬後續決策(R8)。 | +| ADR-11 | 移除「CAS 為 Proprietary」之未經證實敘述 | Final | 授權尚未正式確定,原敘述無依據 | 文件不再宣稱 CAS 授權;授權為待決事項(R1)。 | diff --git a/docs/crypto-animal-studio/episode-package-v1.md b/docs/crypto-animal-studio/episode-package-v1.md new file mode 100644 index 00000000..7d3a4b1b --- /dev/null +++ b/docs/crypto-animal-studio/episode-package-v1.md @@ -0,0 +1,199 @@ +# EpisodePackage v1 + +Status: Sprint 2 (CAS Foundation). Contract version: **1.0**. +Module: `backend/app/crypto_animal_studio/`. + +--- + +## 1. Purpose + +EpisodePackage is the **strict, versioned contract** between Creative OS (Crypto +Animal Studio, "CAS") and Jellyfish. CAS produces a fully-formed episode — script, +storyboard, dialogue, characters, and asset references — and hands it to Jellyfish +as a single validated JSON document. v1 establishes that contract only; it does not +persist anything or create Jellyfish records yet. + +## 2. Architecture role + +CAS sits **upstream** of Jellyfish's production pipeline. Jellyfish natively ingests +a raw script and divides it into shots; CAS instead delivers an **already-completed +storyboard**. EpisodePackage is the hand-off boundary: + +``` +Creative OS (CAS) Jellyfish +news/premise → episode + storyboard ──▶ EpisodePackage (this contract) + + dialogue + characters + assets ──▶ (future) sync import → Chapter + Shots +``` + +The contract lives in a **bounded module** so CAS logic never scatters across +Jellyfish: + +``` +backend/app/crypto_animal_studio/ +├── api/ # thin routes (health now; import later) → ApiResponse +├── application/ # (future) use cases: validate → sync import service +├── domain/ # constants/enums/helpers (SCHEMA_VERSION, SourceType, recurring keys) +├── schemas/ # the ONLY Pydantic models for EpisodePackage (transport/validation) +├── agents/ # (future) CAS-specific agents, if any +└── integrations/ # (future) bridge to Jellyfish Provider/Model/ModelSettings +``` + +Responsibility split (no duplicated models): **schemas** hold every Pydantic model; +**domain** holds only constants/enums/helpers and never imports FastAPI. + +## 3. Why an Episode maps to a Jellyfish Chapter + +Jellyfish has no `Episode` entity. Its hierarchy is `Project → Chapter → Shot`. +A CAS **Episode maps to one Jellyfish Chapter**: a Chapter already is the +chapter-level container that owns a script (`raw_text`) plus its shots and status. +A Jellyfish **Project** represents a **series / production / season** and may contain +multiple CAS Chapters/Episodes. (See the final architecture decisions in the +architecture analysis.) + +## 4. Why shots are imported directly (not re-divided) + +CAS delivers a finished storyboard whose comedy beats, timing, dialogue alignment, +and shot structure are intentional. Therefore the future importer maps +`shots[]` **directly** onto Jellyfish `Shot / ShotDetail / ShotDialogLine` and +**must not** feed the assembled script back through `ScriptDividerAgent`, which +would re-cut the episode and destroy those decisions. `Chapter.raw_text` still stores +the complete generated script for traceability, but Shots are created from the +EpisodePackage, not by re-dividing `raw_text`. + +## 5. Field reference + +Root: `EpisodePackage` + +| Field | Type | Notes | +|---|---|---| +| `schema_version` | str | Must equal `"1.0"`. | +| `episode_id` | str | Non-empty; maps to a Chapter later. | +| `title` | str | Non-empty. | +| `logline` | str | Optional one-line premise. | +| `language` | str | Non-empty (e.g. `en`, `zh`). | +| `source` | NewsSource | Factual/trigger context. | +| `creative_direction` | CreativeDirection | Format, tone, duration target, styles. | +| `characters[]` | CharacterSpec | Narrative characters; keys unique. | +| `assets` | AssetLibrary | actors / scenes / props / costumes. | +| `shots[]` | Shot | ≥ 1; storyboard shots. | +| `metadata` | EpisodeMetadata | Generation trace. | + +`NewsSource`: `source_type` (news|original|fictional|generic), `headline`, `summary`, +`source_url?`, `published_at?`, `factual_notes`. + +`CreativeDirection`: `format`, `tone`, `target_duration_seconds` (>0), `visual_style`, +`comedy_style`, `continuity_notes`. + +`CharacterSpec`: `character_key` (unique, non-empty), `display_name` (non-empty), +`role`, `description`, `actor_key?`, `costume_key?`, `voice_profile?`, `continuity_notes`. + +`AssetLibrary`: `actors[]` (`actor_key`…), `scenes[]` (`scene_key`…), +`props[]` (`prop_key`…), `costumes[]` (`costume_key`…). Keys unique within each category. + +`Shot`: `shot_id` (unique, non-empty), `sequence` (>0, unique), `title`, +`duration_seconds` (>0), `script_excerpt`, `camera?` (CameraSpec), `action`, `dialogue[]`, +`character_keys[]`, `scene_key?`, `prop_keys[]`, `costume_keys[]`, `image_prompt`, +`video_prompt`, `negative_prompt`, `continuity_notes`, `metadata`. + +`CameraSpec` (structured; all fields optional): `shot_type?`, `angle?`, `movement?`. +Values are CAS-local enums that **mirror** Jellyfish's `CameraShotType` / `CameraAngle` / +`CameraMovement` string codes so the future importer maps them cleanly onto +`ShotDetail.camera_shot` / `angle` / `movement`. Allowed codes: + +- `shot_type`: `ECU`, `CU`, `MCU`, `MS`, `MLS`, `LS`, `ELS` +- `angle`: `EYE_LEVEL`, `HIGH_ANGLE`, `LOW_ANGLE`, `BIRD_EYE`, `DUTCH`, `OVER_SHOULDER` +- `movement`: `STATIC`, `PAN`, `TILT`, `DOLLY_IN`, `DOLLY_OUT`, `TRACK`, `CRANE`, `HANDHELD`, `STEADICAM`, `ZOOM_IN`, `ZOOM_OUT` + +The CAS enums are declared inside the bounded module (`domain/episode_package.py`); +the schema does **not** import Jellyfish ORM models or DB enums, preserving the +module boundary. + +`DialogueLine`: `order` (>0, unique within shot), `character_key?`, `text` (non-empty), +`line_mode`. + +`EpisodeMetadata`: `created_at?`, `generator`, `model`, `prompt_version`, `tags[]`. + +### Documented deviations from the proposed structure + +- **`camera`** is a structured `CameraSpec` object with `shot_type` / `angle` / + `movement` (Sprint 2.1 hardening; replaces the earlier free-text field). Values are + CAS-local enums mirroring Jellyfish's camera codes; the schema does not import ORM + enums. All three sub-fields are optional. +- **`duration_seconds`** is a float (`> 0`) to allow fractional seconds. +- Asset key fields are category-specific (`actor_key`, `scene_key`, `prop_key`, + `costume_key`) for unambiguous cross-references. + +### Camera mapping to Jellyfish (for the future importer) + +| EpisodePackage `CameraSpec` | Jellyfish `ShotDetail` | Jellyfish enum | +|---|---|---| +| `camera.shot_type` | `camera_shot` | `CameraShotType` | +| `camera.angle` | `angle` | `CameraAngle` | +| `camera.movement` | `movement` | `CameraMovement` | + +Codes are identical strings, so the importer maps 1:1 with no translation. This is a +documentation-only mapping in Sprint 2.1; no importer is implemented. + +## 6. Validation rules + +All models set `extra="forbid"` (unknown fields rejected). Enforced: + +1. `schema_version == "1.0"`. +2. `episode_id` non-empty. 3. `title` non-empty. 4. `language` non-empty. +5. `creative_direction.target_duration_seconds > 0`. +6. `shots` has ≥ 1 shot. +7. shot `sequence` positive and unique. +8. `shot_id` unique. +9. shot `duration_seconds > 0`. +10. `dialogue.order` positive and unique within a shot. +11. shot `character_keys` (and `dialogue.character_key` when present) exist in `characters`. +12. character `actor_key` exists in `assets.actors` when provided. +13. shot `scene_key` exists in `assets.scenes` when provided. +14. shot `prop_keys` exist in `assets.props`. +15. shot `costume_keys` (and character `costume_key` when provided) exist in `assets.costumes`. +16. `character_key` values unique. +17. asset keys unique within each category. +18. unknown fields rejected (root and nested). + +Field-level rules use `Field` constraints; cross-reference rules use a root +`model_validator(mode="after")` that collects all violations into one error. + +## 7. Example data flow + +``` +docs/crypto-animal-studio/samples/sample-episode-package-v1.json + → EpisodePackage.model_validate(...) # strict validation (this sprint) + → (future) application/import service # synchronous, no Celery + → Project (series/season) + → Chapter (raw_text = full script) + → Shot / ShotDetail / ShotDialogLine (directly from shots[]) + → Character & asset links + → Chapter and Shots visible in Jellyfish +``` + +## 8. Versioning policy + +`schema_version` is the single source of truth (constant `SCHEMA_VERSION` in +`domain/episode_package.py`). v1 accepts exactly `"1.0"`. A breaking change bumps the +major version (`"2.0"`) and adds a parallel schema module; additive, backward-compatible +changes may extend within the same major with clear documentation. + +## 9. Backward compatibility rules + +- Never repurpose or remove an existing field's meaning within a major version. +- Additive fields must be optional with safe defaults. +- Tightening a constraint or removing a field is a breaking change → new major version. +- Consumers must reject unknown `schema_version` values rather than best-effort parse. + +## 10. Known non-goals (v1) + +Explicitly **out of scope** for this sprint: + +- No database persistence. +- No Chapter/Shot record creation. +- No Celery task. +- No LLM invocation. +- No Creative OS agent migration. +- No frontend UI. +- No new Jellyfish enum values (`ProjectStyle` / `ProjectVisualStyle`). +- No second Provider configuration system. diff --git a/docs/crypto-animal-studio/import-mapper-v1.md b/docs/crypto-animal-studio/import-mapper-v1.md new file mode 100644 index 00000000..9f53aad6 --- /dev/null +++ b/docs/crypto-animal-studio/import-mapper-v1.md @@ -0,0 +1,145 @@ +# EpisodePackage → Jellyfish Import Mapper v1 + +Field-by-field mapping performed by the deterministic importer +(`backend/app/crypto_animal_studio/application/import_episode.py`). No LLM, no agents. +See ADR-012 for the design rationale and the contract in `episode-package-v1.md`. + +Legend for **Notes**: *reuse* = resolved by normalized name (trim+lowercase) and reused if +an equivalent exists, else created; *warning* = surfaced in `ImportResult.warnings`, never +silently discarded. + +## Root + +| EpisodePackage field | → Model | → Field | Notes | +|---|---|---|---| +| `episode_id` | `cas_import_ledger` | `episode_id` | Idempotency/traceability; not stored on Chapter. | +| `title` | `Chapter` | `title` | | +| `logline` | `Chapter` | `summary` | | +| (whole package) | `Chapter` | `raw_text` | Deterministic full-script assembly (title + per-shot excerpt + dialogue), **traceability only**; never re-divided. | +| `language` | — | — | Not persisted in v1 (no Chapter/Project language field). *warning-free omission documented here.* | +| `source.*` | — | — | Not persisted in v1 (no target field). Retained in EpisodePackage/ledger payload_hash. | +| `creative_direction.*` | — | — | Not persisted in v1 (no Chapter field for tone/format/style). Reflected only in per-shot mapping where applicable. | +| `shots[]` | `Shot` (+`ShotDetail`,`ShotDialogLine`) | — | One `Shot` per element (see below). `Chapter.storyboard_count = len(shots)`. | +| `characters[]` | `Character` | — | Project-scoped; reuse by name. | +| `assets.*` | `Actor`/`Scene`/`Prop`/`Costume` | — | Global libraries; reuse by name. | +| `metadata.*` | — | — | Not persisted in v1 (kept in payload_hash). | +| `schema_version` | `cas_import_ledger` | `schema_version` | | + +## Chapter (per EpisodePackage) + +| Source | → `Chapter` field | Notes | +|---|---|---| +| — | `id` | New UUID. | +| request `project_id` | `project_id` | Target series/season Project (must exist → else 404). | +| computed | `index` | `max(existing chapter index in project) + 1`. | +| `title` | `title` | | +| `logline` | `summary` | | +| assembled | `raw_text` | Complete script for traceability. | +| — | `condensed_text` | Left empty (no re-division / no ElementExtractor). | +| `len(shots)` | `storyboard_count` | | +| — | `status` | `ChapterStatus.draft`. | + +### Chapter.raw_text assembly (deterministic specification) + +`Chapter.raw_text` stores the complete generated script **for traceability only** (it is +never re-divided). Assembly is deterministic and reproducible +(`domain/mapping.py::assemble_raw_text`): + +1. First line: `# {title}`. If `logline` is non-empty, append it as the next line. +2. Iterate shots **in ascending `sequence` order**. For each shot, emit a section separated + by a blank line: + 1. header `[{sequence}] {title}`; + 2. `script_excerpt` verbatim (if non-empty); + 3. `(action) {action}` (if `action` non-empty); + 4. dialogue **in ascending `order`**, one line each: `{character_key or '—'}: {text}`. +3. Join with newlines; strip leading/trailing whitespace. + +Only present fields are emitted ("where available"). No text is rewritten or summarized. +Because ordering is by `sequence`/`order` and content is copied verbatim, the same +EpisodePackage always yields byte-identical `raw_text` (covered by +`test_assemble_raw_text_is_deterministic`). + +Example (from the sample package): + +``` +# Champagne Before Confirmation +Bull celebrates a rumored inflow record before anyone has actually confirmed it. + +[1] The premature toast +Bull bursts in with the champagne before anyone can react. +(action) Bull kicks the door open, champagne raised over his head. +bull: Record day! We are so back! +bear: Back from what. +``` + +## Shot (per `shots[]` element) + +| Source | → `Shot` field | Notes | +|---|---|---| +| — | `id` | New UUID. | +| Chapter | `chapter_id` | | +| `sequence` | `index` | Unique within chapter (contract-validated). | +| `title` | `title` | | +| `script_excerpt` | `script_excerpt` | | +| — | `status` | `ShotStatus.pending` (never `generating`). | +| — | `skip_extraction` | `True` — CAS storyboard is authoritative; extraction is skipped. | + +## ShotDetail (per shot; shares PK with Shot) + +| Source | → `ShotDetail` field | Notes | +|---|---|---| +| `shot.id` | `id` | 1:1 shared primary key. | +| `camera.shot_type` | `camera_shot` | Defaults to `MS` + *warning* if missing. | +| `camera.angle` | `angle` | Defaults to `EYE_LEVEL` + *warning* if missing. | +| `camera.movement` | `movement` | Defaults to `STATIC` + *warning* if missing. | +| `duration_seconds` | `duration` | `round()` to int, min 1. | +| `action` | `description` | | +| `image_prompt` | `key_frame_prompt` | | +| `video_prompt` | — | No ShotDetail field → *warning* (not discarded). | +| `negative_prompt` | — | No ShotDetail field → *warning* (not discarded). | +| resolved `scene_key` | `scene_id` | Via reused/created `Scene` (nullable). | + +## ShotDialogLine (per `shot.dialogue[]`) + +| Source | → `ShotDialogLine` field | Notes | +|---|---|---| +| `order` | `index` | Positive, unique within shot (contract-validated). | +| `text` | `text` | Never rewritten. | +| `line_mode` | `line_mode` | Validated against DIALOGUE/VOICE_OVER/OFF_SCREEN/PHONE; invalid → `DIALOGUE` + *warning*. | +| `character_key` | `speaker_character_id` | Resolved to the imported `Character.id` (nullable). | +| `character_key` → display_name | `speaker_name` | Backfilled from `characters[]`. | + +## Character (per `characters[]`) — Character ≠ Actor (never merged) + +| Source | → `Character` field | Notes | +|---|---|---| +| — | `id` | New UUID (or reused). | +| request `project_id` | `project_id` | Project-scoped. | +| `display_name` | `name` | Reuse by normalized name within project. | +| `description` | `description` | | +| Project | `style`, `visual_style` | Inherited from the target Project (no new enum values). | +| resolved `actor_key` | `actor_id` | Via reused/created `Actor`; if no `actor_key` → left unset + *warning*. | +| resolved `costume_key` | `costume_id` | Via reused/created `Costume` (nullable). | + +## Assets → global libraries (reuse-first, by normalized name) + +| Source | → Model | → Fields | Notes | +|---|---|---|---| +| `assets.actors[]` | `Actor` | `name`←display_name/key, `description`, `style`/`visual_style`←Project | Reuse by name; linked to Character via `actor_id`. | +| `assets.scenes[]` (via `shot.scene_key`) | `Scene` | same shape | Linked to shot via `ShotDetail.scene_id` **and** `ProjectSceneLink(project,chapter,shot,scene)`. | +| `assets.props[]` (via `shot.prop_keys`) | `Prop` | same shape | Linked via `ProjectPropLink(project,chapter,shot,prop)`. | +| `assets.costumes[]` (via `shot.costume_keys`/character) | `Costume` | same shape | Linked via `ProjectCostumeLink(...)` and/or `Character.costume_id`. | +| `shot.character_keys[]` | `ShotCharacterLink` | `shot_id`,`character_id`,`index` | Ordered shot cast. | + +## Normalization & reuse + +- Key comparison uses `trim` → `lowercase` (`normalize_key`). +- Within one import transaction, a given normalized key resolves to exactly one entity + (in-transaction cache) — **no duplicate assets in one transaction**. +- Reuse queries match existing rows case-insensitively by name (`func.lower(name)`). + +## Transaction, dry-run, idempotency (summary) + +- **One transaction**, one commit (owned by the request session `get_db`); any failure → full rollback, no partial Chapter/Shots. +- **Dry-run** performs validation + mapping + reuse lookup + warnings, then rolls back (writes nothing). +- **Idempotency** via `cas_import_ledger` (durable): same `(project, key)` + same `payload_hash` → replay existing chapter; same key + different payload → 409; same `(project, episode)` under another key → 409. Ledger design: see **ADR-013**. The ledger row is written on the **same session/transaction** as all imported rows; a ledger insert failure rolls back the entire episode. diff --git a/docs/crypto-animal-studio/samples/sample-episode-package-v1.json b/docs/crypto-animal-studio/samples/sample-episode-package-v1.json new file mode 100644 index 00000000..485840bc --- /dev/null +++ b/docs/crypto-animal-studio/samples/sample-episode-package-v1.json @@ -0,0 +1,169 @@ +{ + "schema_version": "1.0", + "episode_id": "CAS-E001", + "title": "Champagne Before Confirmation", + "logline": "Bull celebrates a rumored inflow record before anyone has actually confirmed it.", + "language": "en", + "source": { + "source_type": "fictional", + "headline": "Fictional Exchange Reports Record Inflows Into Animal Token Fund", + "summary": "A made-up trading desk claims an unusually strong day of inflows into a fictional fund. Nothing is confirmed and no real market data is referenced.", + "source_url": null, + "published_at": "2026-01-02T09:00:00Z", + "factual_notes": "Entirely fictional scenario for demonstration. Not investment advice; no price prediction; no reliance on live facts." + }, + "creative_direction": { + "format": "short_form_vertical", + "tone": "deadpan", + "target_duration_seconds": 45, + "visual_style": "anime", + "comedy_style": "false_confidence + callback", + "continuity_notes": "Keep Walter dry and sparse. The champagne is a running visual gag across the episode." + }, + "characters": [ + { + "character_key": "bull", + "display_name": "Bull", + "role": "main", + "description": "Relentlessly optimistic trader who celebrates first and checks later.", + "actor_key": "actor_bull", + "costume_key": "costume_trader_vest", + "voice_profile": "loud, upbeat", + "continuity_notes": "Always holding something celebratory." + }, + { + "character_key": "bear", + "display_name": "Bear", + "role": "straight_man", + "description": "Skeptical and tired; expects the worst on principle.", + "actor_key": "actor_bear", + "costume_key": "costume_hoodie", + "voice_profile": "low, weary", + "continuity_notes": "Rarely makes eye contact with the celebration." + }, + { + "character_key": "walter", + "display_name": "Walter", + "role": "deadpan_anchor", + "description": "Quiet, dry closer who delivers the final understated line.", + "actor_key": "actor_walter", + "costume_key": null, + "voice_profile": "flat, minimal", + "continuity_notes": "Speaks at most once; the punchline lands on his silence." + } + ], + "assets": { + "actors": [ + { "actor_key": "actor_bull", "display_name": "Bull", "description": "Anthropomorphic bull, broad-shouldered, expressive." }, + { "actor_key": "actor_bear", "display_name": "Bear", "description": "Anthropomorphic bear, slouched posture." }, + { "actor_key": "actor_walter", "display_name": "Walter", "description": "Small, neat, unreadable expression." } + ], + "scenes": [ + { "scene_key": "scene_trading_floor", "display_name": "Trading Floor", "description": "Open office with monitors, a doom board, and a snack table." } + ], + "props": [ + { "prop_key": "prop_champagne", "display_name": "Champagne Bottle", "description": "Oversized celebratory bottle that keeps reappearing." }, + { "prop_key": "prop_phone", "display_name": "Phone", "description": "Screen shows an unconfirmed notification." } + ], + "costumes": [ + { "costume_key": "costume_trader_vest", "display_name": "Trader Vest", "description": "Bright vest with too many pockets." }, + { "costume_key": "costume_hoodie", "display_name": "Grey Hoodie", "description": "Worn, oversized, hood usually up." } + ] + }, + "shots": [ + { + "shot_id": "SC01", + "sequence": 1, + "title": "The premature toast", + "duration_seconds": 8, + "script_excerpt": "Bull bursts in with the champagne before anyone can react.", + "camera": { "shot_type": "MS", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Bull kicks the door open, champagne raised over his head.", + "dialogue": [ + { "order": 1, "character_key": "bull", "text": "Record day! We are so back!", "line_mode": "DIALOGUE" }, + { "order": 2, "character_key": "bear", "text": "Back from what.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_champagne"], + "costume_keys": ["costume_trader_vest", "costume_hoodie"], + "image_prompt": "trading floor, bull raising a champagne bottle, bear slouched in background, anime, cel-shaded", + "video_prompt": "bull kicks door open and raises champagne, bear does not look up, 8 seconds", + "negative_prompt": "no real logos, no real ticker symbols, no text overlays", + "continuity_notes": "Establish the champagne gag here.", + "metadata": { "beat": "hook" } + }, + { + "shot_id": "SC02", + "sequence": 2, + "title": "The unread notification", + "duration_seconds": 10, + "script_excerpt": "Bear points at the phone; the number is unconfirmed.", + "camera": { "shot_type": "CU", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Bear slides the phone across the desk; the screen says 'PENDING'.", + "dialogue": [ + { "order": 1, "character_key": "bear", "text": "It literally says pending.", "line_mode": "DIALOGUE" }, + { "order": 2, "character_key": "bull", "text": "Pending is basically confirmed.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_phone"], + "costume_keys": ["costume_trader_vest", "costume_hoodie"], + "image_prompt": "close up of a phone reading PENDING, bull leaning in confidently, anime", + "video_prompt": "bear slides phone forward, bull waves it off, 10 seconds", + "negative_prompt": "no real app UI, no real brand names", + "continuity_notes": "Callback target for the ending.", + "metadata": { "beat": "trigger" } + }, + { + "shot_id": "SC03", + "sequence": 3, + "title": "The escalation", + "duration_seconds": 12, + "script_excerpt": "Bull pours anyway; Bear braces for the correction.", + "camera": { "shot_type": "MLS", "angle": "LOW_ANGLE", "movement": "HANDHELD" }, + "action": "Bull pops the champagne; foam goes everywhere; Bear covers his monitor.", + "dialogue": [ + { "order": 1, "character_key": "bull", "text": "To confirmation that definitely will happen!", "line_mode": "DIALOGUE" }, + { "order": 2, "character_key": "bear", "text": "You are cleaning that up.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_champagne"], + "costume_keys": ["costume_trader_vest"], + "image_prompt": "champagne foam exploding across a trading desk, bear shielding a monitor, anime, dynamic", + "video_prompt": "champagne pops, foam sprays, bear ducks, 12 seconds", + "negative_prompt": "no real market charts, no numbers on screens", + "continuity_notes": "Payoff of the champagne gag from SC01.", + "metadata": { "beat": "escalation" } + }, + { + "shot_id": "SC04", + "sequence": 4, + "title": "The quiet correction", + "duration_seconds": 9, + "script_excerpt": "Walter reads the phone and says one thing.", + "camera": { "shot_type": "CU", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Walter picks up the phone, glances at it, sets it face down. Beat. He walks off.", + "dialogue": [ + { "order": 1, "character_key": "walter", "text": "It was rounding.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["walter", "bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_phone"], + "costume_keys": [], + "image_prompt": "walter setting a phone face down on a foam-covered desk, deadpan, anime", + "video_prompt": "walter reads phone, sets it down, walks off, freeze on bull's face, 9 seconds", + "negative_prompt": "no explanatory text, no captions after the line", + "continuity_notes": "Punchline lands on Walter's exit; do not add an explaining beat after this.", + "metadata": { "beat": "punchline" } + } + ], + "metadata": { + "created_at": "2026-01-02T09:05:00Z", + "generator": "creative-os", + "model": "sample-fixture", + "prompt_version": "cas-episode-v1", + "tags": ["sample", "fictional", "crypto-animal-studio"] + } +} diff --git a/docs/implementation-log.md b/docs/implementation-log.md new file mode 100644 index 00000000..d1e43447 --- /dev/null +++ b/docs/implementation-log.md @@ -0,0 +1,215 @@ +# Implementation Log + +Chronological log of CAS↔Jellyfish integration sprints. Newest first. + +--- + +## Sprint 3 — EpisodePackage Importer v1 + +Date: 2026-07-24 + +### Summary +Implemented the deterministic EpisodePackage → Jellyfish importer inside the CAS bounded +module: validate → map → reuse → create → rollback, in exactly one transaction, with +dry-run and **durable idempotency**. Added `POST /api/v1/crypto-animal-studio/import`, a +lightweight import-ledger table (approved) + migration, a full field mapping doc, and unit ++ integration + API tests. No LLM / ScriptDivider / ElementExtractor / Celery / Redis / +providers / frontend. + +### Phase 0 — repository discovery (key findings) +- Reusable write primitive: `services/common/create_and_refresh` (`db.add` + `flush` + `refresh`, **no commit**); studio services are thin wrappers over it. **No dedicated chapters/aggregate-import service exists**, so the importer composes writes on the single request session via that shared helper (documented reason for constructing ORM directly). +- Transaction pattern: one `AsyncSession` from `get_db` (commit once on success, rollback on error). Importer only flushes → one transaction, one commit. +- `ShotDetail` shares its PK with `Shot` (1:1). Camera fields are NOT NULL → missing camera is defaulted + warned. `ShotStatus.generating` is deprecated → importer uses `pending`. +- **No durable metadata field** on Chapter/Project for idempotency → lightweight ledger table approved. + +### Architecture decisions +- Idempotency persisted in new `cas_import_ledger` (bookkeeping table; unique `(project,key)` and `(project,episode)`); canonical SHA-256 payload hash detects changed payloads. This is durable (survives restarts), not process-memory. +- Reuse-first assets with `trim+lowercase` normalized-name matching; in-transaction cache prevents duplicate assets. Character ≠ Actor (never merged). +- Dry-run builds + flushes (to validate) then rolls back → writes nothing. +- Unmapped optional fields (`language`, `source`, `creative_direction`, `video_prompt`, `negative_prompt`) become warnings, never silently discarded. + +### Files created +- `backend/app/crypto_animal_studio/domain/import_ledger.py` (CasImportLedger ORM) +- `backend/app/crypto_animal_studio/domain/mapping.py` (pure mapping helpers) +- `backend/app/crypto_animal_studio/application/hashing.py` (canonical payload hash) +- `backend/app/crypto_animal_studio/application/import_result.py` (ImportResult/ImportCounts) +- `backend/app/crypto_animal_studio/application/import_episode.py` (importer service + exceptions) +- `backend/app/crypto_animal_studio/schemas/import_request.py`, `schemas/import_result.py` +- `backend/app/crypto_animal_studio/api/import_episode.py` (POST /import route) +- `backend/sql/009-add-cas-import-ledger.sql` (migration) +- `backend/tests/test_cas_import_unit.py`, `test_cas_import_episode.py`, `test_cas_import_api.py` +- `docs/crypto-animal-studio/import-mapper-v1.md` + +### Files changed +- `backend/app/crypto_animal_studio/api/__init__.py` — register import route. +- `backend/app/core/db.py` — register ledger model in `init_db()` create_all. +- `docs/adr/ADR-012-episode-importer.md` — status Accepted; §10 durable persistence resolved. +- `docs/implementation-log.md` — this entry. + +### Tests executed & results +- `pytest test_cas_import_unit.py test_cas_import_episode.py test_cas_import_api.py` → **21 passed** (8 unit + 10 integration + 3 API). +- Full CAS set (schema + health + import) → **47 passed, 0 skipped** (with the sandbox 3.11-name shim so health API tests run; see limitation). +- Integration uses in-memory SQLite (StaticPool) with all models + ledger created via `Base.metadata.create_all`. + +### Idempotency status +**Fully durable using the approved `cas_import_ledger` table** (not an existing field, not process memory): same key + same payload → replay; same key + different payload → 409; same episode under another key → 409. Verified by integration tests `test_idempotent_replay_returns_existing`, `test_same_key_different_payload_conflicts`, `test_same_episode_other_key_rejected`. + +### Known limitations +- Sandbox is Python 3.10; repo targets 3.12 (3.12 toolchain download blocked). Health API tests execute via a `/tmp` sitecustomize shim (`datetime.UTC`, `typing.Self`); on 3.12/CI they run natively. Importer unit/integration/API tests run on 3.10 unshimmed. +- `video_prompt` / `negative_prompt` and `language`/`source`/`creative_direction` have no Jellyfish target field in v1 → surfaced as warnings (documented in the mapper). +- OpenAPI/frontend client not regenerated (out of scope; run `pnpm run openapi:update` before FE use). +- `pylint` not run in sandbox; recommend `uv run pylint app/crypto_animal_studio` on 3.12. + +### Rollback instructions +1. Revert changed files: `backend/app/crypto_animal_studio/api/__init__.py`, `backend/app/core/db.py`, `docs/adr/ADR-012-episode-importer.md`, `docs/implementation-log.md`. +2. Delete new files: importer modules, schemas (import_request/result), api/import_episode.py, domain/import_ledger.py + mapping.py, application/hashing.py + import_result.py + import_episode.py, `backend/sql/009-add-cas-import-ledger.sql`, the three import test files, `docs/crypto-animal-studio/import-mapper-v1.md`. +3. If the ledger table was already applied to a database, drop it: `DROP TABLE cas_import_ledger;` (no other tables were altered). + +### Hardening addendum (2026-07-24, no new sprint) +- Added **ADR-013 — CAS Import Ledger** (`docs/adr/ADR-013-cas-import-ledger.md`): full table + schema, FKs/deletion behavior, unique constraints, canonical hashing, replay/conflict, + transaction boundaries, failed-import behavior, retention/cleanup, rollback migration, and + why it is not an Episode domain table (incl. `UNIQUE(project_id, episode_id)` as a + conservative v1 policy pending a separate reimport/update decision). ADR-012 now references it. +- **Transaction verification**: confirmed ledger + Chapter + Shots + ShotDetails + dialogue + + assets + links all use the one `AsyncSession` in a single transaction. Added + `test_ledger_insert_failure_rolls_back_entire_episode` (forces the ledger insert to fail → + asserts every table count is 0). +- **raw_text**: `assemble_raw_text` now also includes shot `action` (ordered by sequence, then + dialogue by order); fully specified in `import-mapper-v1.md`; determinism test added. +- **Migration review**: `sql/009` aligned to repo conventions (VARCHAR(64) UUID PK, VARCHAR FKs, + FK indexes via unique-key left prefix + explicit chapter index, `fk_`/`uq_` naming, DATETIME + defaults incl. `ON UPDATE`); removed a redundant `project_id` index; documented forward-only + rollback (`DROP TABLE cas_import_ledger;`). +- CAS tests after hardening: **49 passed, 0 skipped** (23 schema + 3 health + 9 import-unit + 11 import-integration + 3 import-api). + +--- + +## Sprint 2.1 — CAS Foundation Hardening + +Date: 2026-07-24 + +### Summary +Hardened the EpisodePackage v1 contract and added approved governance docs. Replaced the +`camera` free-text field with a structured `CameraSpec` object (`shot_type` / `angle` / +`movement`) using **CAS-local** enums that mirror Jellyfish's camera codes — without +importing any ORM model or DB enum. Added `MASTER_PLAN.md` and `docs/architecture-analysis.md`. +No database persistence, importer, Celery, or frontend work (per scope). + +### Architecture decisions +- **Structured camera**: `Shot.camera` is now `CameraSpec { shot_type?, angle?, movement? }`. + Enums (`CasShotType`/`CasCameraAngle`/`CasCameraMovement`) live in `domain/episode_package.py` + and use identical string codes to Jellyfish `CameraShotType`/`CameraAngle`/`CameraMovement`, + so the future importer maps 1:1 to `ShotDetail.camera_shot`/`angle`/`movement`. The schema + does **not** import `app.models` — the module boundary is preserved. +- Governance docs record the 9 approved final decisions (Episode→Chapter, Project=series, + direct Shot creation, no re-division through `ScriptDividerAgent`, `raw_text` traceability, + no duplicate systems, provider convergence, synchronous first importer, no new enums). + +### Files created +- `MASTER_PLAN.md` +- `docs/architecture-analysis.md` + +### Files changed +- `backend/app/crypto_animal_studio/domain/episode_package.py` — add `CasShotType` / `CasCameraAngle` / `CasCameraMovement`. +- `backend/app/crypto_animal_studio/domain/__init__.py` — export the new enums. +- `backend/app/crypto_animal_studio/schemas/episode_package.py` — add `CameraSpec`; `Shot.camera` now `Optional[CameraSpec]`. +- `backend/app/crypto_animal_studio/schemas/__init__.py` — export `CameraSpec`. +- `backend/tests/test_cas_episode_package_schema.py` — add 5 camera tests (parse, optional, invalid shot_type, invalid movement, unknown camera field). +- `docs/crypto-animal-studio/samples/sample-episode-package-v1.json` — camera as structured objects. +- `docs/crypto-animal-studio/episode-package-v1.md` — CameraSpec reference + camera→ShotDetail mapping + updated deviation note. +- `docs/implementation-log.md` — this entry. + +### Tests executed +- `pytest backend/tests/test_cas_episode_package_schema.py` (schema) and + `backend/tests/test_cas_health_api.py` (health API). +- Isolated endpoint verification harness; `py_compile` on changed files; sample JSON re-validated. + +### Test results +- **Schema tests: 23 passed** (18 prior + 5 new camera tests). +- **Health API tests**: see "Known limitations" — they run and pass under Python 3.12 + (repo target); in this sandbox (Python 3.10 only, 3.12 download blocked) they are skipped + by the repo's own `conftest` guard. Endpoint re-verified working via the isolated harness. + +### Known limitations +- The repo targets Python 3.12; this sandbox only has 3.10 and cannot download a 3.12 + toolchain (GitHub release host not reachable). The 3 health API tests therefore **skip** + here via the repo's `conftest` (`app=None`), not due to any code fault. Run + `uv run pytest` on 3.12 to execute them with zero skips. +- OpenAPI/frontend generated client not regenerated (out of sprint scope; no route shape + change beyond Sprint 2's already-registered health route). + +### Rollback instructions +1. Revert the changed module/test/doc files listed above to their Sprint 2 state. +2. Delete `MASTER_PLAN.md` and `docs/architecture-analysis.md` if the governance docs + are not wanted: `rm -f MASTER_PLAN.md docs/architecture-analysis.md`. +3. No DB/migration/ORM/enum/provider changes were made — no data or schema rollback needed. +4. Via VCS: `git restore ` and `git clean -f MASTER_PLAN.md docs/architecture-analysis.md`. + +--- + +## Sprint 2 — CAS Foundation (EpisodePackage v1) + +Date: 2026-07-24 + +### Summary +Established the bounded Crypto Animal Studio (CAS) backend module and the strict, +versioned **EpisodePackage v1** contract between Creative OS and Jellyfish. Added a +lightweight CAS health endpoint, schema + API tests, a valid sample package, and +contract documentation. **No** database persistence, episode import, Celery, LLM, +or frontend work was done (per sprint scope). + +### Architecture decisions +- **Bounded module** `backend/app/crypto_animal_studio/` (`api/domain/schemas/application/agents/integrations`) so CAS logic never scatters across Jellyfish. +- **Responsibility split**: `schemas/` is the single source of Pydantic transport/validation models; `domain/` holds only constants/enums/helpers (`SCHEMA_VERSION`, `SourceType`, recurring character keys) and does **not** import FastAPI. +- **Contract shape** follows the sprint spec; documented deviations: `camera` is a single free-text field (not a nested object) in v1; `duration_seconds` is a float `>0`; asset key fields are category-specific (`actor_key`/`scene_key`/`prop_key`/`costume_key`). +- **Validation**: field-level constraints via `Field`; all cross-reference checks via one root `model_validator(mode="after")`; every model `extra="forbid"`. +- **Health route** reuses Jellyfish `ApiResponse`, registered through the existing `app/api/v1` aggregation (no independent FastAPI app), at `GET /api/v1/crypto-animal-studio/health`. +- Reaffirmed final decisions in docs: Episode→Chapter, Project=series/season, shots imported directly (never re-sent through `ScriptDividerAgent`), `raw_text` kept for traceability. + +### Files created +- `backend/app/crypto_animal_studio/__init__.py` +- `backend/app/crypto_animal_studio/api/__init__.py` +- `backend/app/crypto_animal_studio/api/health.py` +- `backend/app/crypto_animal_studio/domain/__init__.py` +- `backend/app/crypto_animal_studio/domain/episode_package.py` +- `backend/app/crypto_animal_studio/schemas/__init__.py` +- `backend/app/crypto_animal_studio/schemas/episode_package.py` +- `backend/app/crypto_animal_studio/application/__init__.py` +- `backend/app/crypto_animal_studio/agents/__init__.py` +- `backend/app/crypto_animal_studio/integrations/__init__.py` +- `backend/tests/test_cas_episode_package_schema.py` +- `backend/tests/test_cas_health_api.py` +- `docs/crypto-animal-studio/samples/sample-episode-package-v1.json` +- `docs/crypto-animal-studio/episode-package-v1.md` +- `docs/implementation-log.md` (this file) + +### Files changed +- `backend/app/api/v1/__init__.py` — register CAS router under `/crypto-animal-studio` (only existing file modified). + +### Tests executed +- `pytest backend/tests/test_cas_episode_package_schema.py backend/tests/test_cas_health_api.py` +- Isolated endpoint verification harness (minimal FastAPI app mounting only the CAS router). +- `python -m py_compile` on all new/changed Python files. + +### Test results +- **Schema tests: 18 passed** (valid sample + version, empty-field, duration, empty-shots, duplicate sequence/shot_id/dialogue-order, unknown character/actor/scene/prop/costume refs, duplicate character/asset keys, unknown field at root + nested). +- **Health API tests: 3 skipped** in the sandbox. Reason: the repo's own `tests/conftest.py` guard sets `app=None` when `app.main` cannot import; the sandbox runs Python 3.10 while the repo targets 3.12 and uses 3.11+ names (`datetime.UTC`, `typing.Self`). This is an **environment limitation, not a code fault** — on CI / Python 3.12 the app imports and these 3 tests run. +- **Endpoint proven working** via an isolated harness: `GET /api/v1/crypto-animal-studio/health` → `200`, `ApiResponse` envelope, `data = {"service":"crypto-animal-studio","status":"ok","schema_version":"1.0"}`. +- `py_compile`: OK for all new/changed files. Sample JSON: well-formed and passes `EpisodePackage` validation. + +### Known limitations +- Health API tests could not execute in this sandbox (Python 3.10; repo needs 3.11+). They should pass under `uv run pytest` on 3.12. +- OpenAPI/frontend generated client **not** regenerated (`pnpm run openapi:update`): sprint forbids touching generated clients and no server/frontend was run here. Follow-up before merge if the new route must appear in the FE client. +- Full backend suite not run end-to-end here (full-app import is slow/blocked on 3.10); only targeted CAS tests + compile checks were run. +- `pylint` not run in-sandbox (repo DoD expects it); recommend `uv run pylint app/crypto_animal_studio` on a 3.12 dev env. + +### Rollback instructions +1. Revert the one modified file: restore `backend/app/api/v1/__init__.py` (remove the `crypto_animal_studio` import and its `include_router(...)` block). +2. Delete the new module and artifacts: + - `rm -rf backend/app/crypto_animal_studio` + - `rm -f backend/tests/test_cas_episode_package_schema.py backend/tests/test_cas_health_api.py` + - `rm -rf docs/crypto-animal-studio` + - `rm -f docs/implementation-log.md` +3. No DB/migration/ORM/enum/provider changes were made, so no data or schema rollback is required. +4. Equivalent via VCS: `git restore backend/app/api/v1/__init__.py` and `git clean -fd backend/app/crypto_animal_studio backend/tests/test_cas_*.py docs/crypto-animal-studio docs/implementation-log.md`. From c2b3ea5634a16bec0d101d928a3a2d248d55788f Mon Sep 17 00:00:00 2001 From: shiuyu Date: Sun, 26 Jul 2026 15:06:57 +0800 Subject: [PATCH 2/5] docs(cas): establish Crypto Animal Bible v1 canon --- .../ADR-015-crypto-animal-bible-v1-canon.md | 124 +++++ .../Crypto_Animal_Bible_v1.md | 440 ++++++++++++++++++ .../bible-v1-implementation-gap-report.md | 249 ++++++++++ 3 files changed, 813 insertions(+) create mode 100644 docs/adr/ADR-015-crypto-animal-bible-v1-canon.md create mode 100644 docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md create mode 100644 docs/crypto-animal-studio/bible-v1-implementation-gap-report.md diff --git a/docs/adr/ADR-015-crypto-animal-bible-v1-canon.md b/docs/adr/ADR-015-crypto-animal-bible-v1-canon.md new file mode 100644 index 00000000..cd912bec --- /dev/null +++ b/docs/adr/ADR-015-crypto-animal-bible-v1-canon.md @@ -0,0 +1,124 @@ +# ADR-015 — Crypto Animal Bible v1 is Canon + +- Status: **Accepted** +- Date: 2026-07-26 +- Deciders: Product Owner (decision), Architect/CTO (review), Engineer (record) +- Canonical document: [`docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md`](../crypto-animal-studio/Crypto_Animal_Bible_v1.md) +- Related: [ADR-012](ADR-012-episode-importer.md) (importer), [ADR-013](ADR-013-cas-import-ledger.md) (ledger), + [ADR-014](ADR-014-cas-production-pipeline.md) (production pipeline), + contract: [`episode-package-v1.md`](../crypto-animal-studio/episode-package-v1.md), + gap analysis: [`bible-v1-implementation-gap-report.md`](../crypto-animal-studio/bible-v1-implementation-gap-report.md) +- Scope: **governance only.** This ADR changes no code, no schema, no tests, and no samples. + +--- + +## 1. Context + +Crypto Animal Studio (CAS) now has a complete creative and brand specification: +**Crypto Animal Bible v1**. Until now the codebase carried an earlier, informal demo cast +(six animal keys) and a demo fixture with a 45-second target duration. Those predate the +Bible and were never a brand decision — they were scaffolding for contract and pipeline work +(Sprints 2–4). + +Without an explicit decision, two competing "truths" would coexist: the Bible's launch trio +and the legacy demo cast. That ambiguity would leak into prompts, samples, validation rules, +and eventually into published video. This ADR resolves it. + +## 2. Decision + +### 2.1 Bible v1 is the canonical source of truth + +`docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md` is the authoritative specification for: +world setting, character identities and personalities, character visual consistency, art +direction, humor and dialogue rules, cinematography, prompt construction, continuity +requirements, prohibited content, and quality-control rules. + +Bible v1 is **immutable**. It must not be rewritten or modified without explicit +authorization. Any permanent change to a locked field requires a new Bible version plus the +change-control steps in Bible §13 (updated character reference assets, prompt-regression +test, review of an existing episode under the new configuration). + +### 2.2 The canonical launch trio + +The primary cast is **Bruno Bull**, **Boris Bear**, and **Milo Cat**, with the locked visual +identities, personalities, voice/dialogue rhythms, and motion language defined in Bible §3. + +### 2.3 Canonical runtime and format + +- Runtime: **15–30 seconds** per episode. +- Format: **9:16 vertical video**. +- **English dialogue with Traditional Chinese subtitles is required for publishable episodes.** + +### 2.4 Legacy status of the six-character demo + +The Bible **supersedes**, as a creative specification, the older six-character demo cast and +the 45-second demo duration. Those artifacts remain in the repository only as technical +fixtures for existing contract/pipeline tests. + +### 2.5 Legacy compatibility policy + +- The existing character keys `bull, bear, fox, hammy, monkey, walter` are **legacy technical + compatibility values**. +- In this step we **do not delete, rename, or reinterpret** existing enum/constant values. +- **No destructive data migration** is performed. +- Legacy characters may remain supported by the generic EpisodePackage contract, but they are + **not part of the current canonical launch cast**. +- `bull` and `bear` **must not be treated as sufficient visual identities**. Canonical + production prompts must eventually carry Bruno's and Boris's full Bible identities + (Bible §10: "Prompts must not rely on character names alone"). +- **Walter must not be substituted for Milo** in new canonical content. + +### 2.6 Why existing enums are not removed now + +1. **Non-destructive by default.** Removing or renaming values risks breaking stored data and + the green Sprint 4 baseline (68 CAS tests) for no immediate creative benefit. +2. **Separation of concerns.** The canonical cast is a *creative* decision; the character keys + are a *technical* compatibility surface. The Bible constrains what we produce, not what the + generic contract can represent. +3. **The contract is intentionally generic.** EpisodePackage v1 describes arbitrary characters; + restricting it to three would couple a general interchange format to one show's launch cast. +4. **Sequencing.** Canonical enforcement belongs with Prompt Builder v1 and the first real + video work, where it can be implemented and tested together rather than as a risky + stand-alone rename. + +### 2.7 EpisodePackage v1 remains unchanged + +This decision introduces **no** change to EpisodePackage v1. The contract, its validation +rules, the importer contract, the production pipeline, tests, and samples are untouched by +this ADR. + +### 2.8 Schema evolution requires a separate approved proposal + +Bible requirements that have no representation in EpisodePackage v1 — notably Traditional +Chinese subtitle text, the fact-card/CTA beat, and explicit format fields (aspect ratio, fps, +resolution) — **must not** be added ad hoc. They require a separately approved **v1.1 or v2** +schema proposal with its own ADR, following the existing versioning policy in +`episode-package-v1.md`. + +### 2.9 Prompt Builder v1 is a separate scoped implementation + +The current deterministic Prompt Builder v0 (Sprint 4) does not satisfy Bible §10. Bringing +prompts to canon — the 11 required blocks, world/style anchors, full immutable character +identities, continuity/reference injection, and Bible-derived exclusions — is a **separate, +scoped sprint** and is explicitly **not** authorized by this ADR. + +### 2.10 Automated checks and human review must be distinguished + +Bible §11's production quality gate mixes machine-verifiable constraints with human +judgement. Any future "quality gate" work must state, per criterion, whether it is an +**automated check** or a **human review check**. Claiming the gate is "implemented" without +that distinction is not acceptable. + +## 3. Consequences + +**Positive.** A single unambiguous creative source of truth; conflicts now surface as review +findings instead of silent drift; the Sprint 4 baseline stays green because nothing is +migrated; canonical enforcement is deliberately sequenced with the work that needs it. + +**Trade-offs.** Legacy and canonical casts coexist temporarily, so documentation and samples +will disagree with the Bible until the compatibility work lands — the gap report tracks this +explicitly. Publishable episodes are blocked until subtitle representation and Prompt Builder +v1 exist; those are recorded as separate approvals, not assumed. + +**Non-goals of this ADR.** No code, schema, test, sample, or Bible modification; no enum +removal; no data migration; no Prompt Builder implementation. diff --git a/docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md b/docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md new file mode 100644 index 00000000..56dc6193 --- /dev/null +++ b/docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md @@ -0,0 +1,440 @@ +# Crypto Animal Bible v1 + +> Project: Crypto Animal Studio +> Status: Production baseline +> Version: 1.0 +> Primary format: 9:16 vertical short video, 15–30 seconds +> Primary language: English dialogue with Traditional Chinese subtitles + +## 1. Brand Core + +### One-line premise + +In **Block Street**, animal traders turn real crypto-market events into fast, cinematic office comedy. + +### Brand promise + +Every episode should let viewers: + +1. understand one real market event; +2. feel the emotion of that event; +3. remember one character-driven joke. + +### Creative formula + +`Real market trigger + conflicting trader reactions + visual escalation + one factual landing` + +### Tone + +- Smart, fast, expressive, meme-aware. +- Comedy comes from personality conflict rather than random chaos. +- Market information is accurate, but characters may react irrationally. +- Suitable for a broad social-media audience; no explicit violence, sexual content, hate, or profanity. +- Never present speculation as guaranteed financial advice. + +## 2. World: Block Street + +### Setting + +Block Street is a dense financial district where the global crypto market behaves like a physical ecosystem. Candlestick charts illuminate skyscrapers, price alerts shake office windows, liquidation events trigger red emergency lights, and green candles can launch objects off desks. + +The main location is **The Burrow**, a compact high-tech trading studio operated by three recurring characters. It contains: + +- a central curved trading desk; +- a wall-sized BTC chart; +- three identifiable workstations; +- a coffee station; +- a glass wall overlooking Block Street; +- practical red and green market lighting. + +### World rules + +1. Real market data drives the episode premise. +2. Charts, alerts, headlines, and market events may affect the physical environment. +3. Animals speak and work like modern young professionals while retaining species-specific behavior. +4. Characters do not magically change size, clothing, fur pattern, or signature props between shots. +5. Phones and screens are supporting elements; important readable text is added in post-production. +6. The world may exaggerate emotion and physics, but the final factual statement must remain honest. + +## 3. Core Cast + +## 3.1 Bruno Bull + +**Story role:** momentum trader and comic accelerator +**Species:** anthropomorphic bull +**Silhouette:** broad shoulders, large curved horns, strong upper body +**Apparent age:** late 20s +**Height relationship:** tallest of the trio + +### Locked visual identity + +- Warm chestnut-brown fur. +- Cream muzzle. +- Symmetrical ivory horns with dark tips, curving outward then upward. +- Thick dark eyebrows. +- Amber-brown eyes. +- Dark forest-green rolled-sleeve shirt. +- Mustard-yellow tie, usually loosened. +- Black smartwatch on left wrist. +- No jacket, no hat, no glasses. + +### Personality + +- Optimistic, impulsive, loud, loyal. +- Treats every green candle as destiny. +- Acts first and checks risk later. +- Wants the team to celebrate with him. + +### Voice and dialogue + +- Energetic, warm baritone. +- Short declarations and celebratory slang. +- Usually 2–8 spoken words per line. +- Example rhythm: “We are so back!” / “That candle is beautiful.” + +### Motion language + +- Enters quickly. +- Leans forward. +- Uses broad arm gestures. +- Hooves hit the floor with weight. +- Horns and tie create secondary motion. + +### Comedy weakness + +Overconfidence. He celebrates before confirmation. + +## 3.2 Boris Bear + +**Story role:** risk manager, skeptic, and pressure-release punchline +**Species:** anthropomorphic bear +**Silhouette:** heavy, rounded, compact power +**Apparent age:** early 30s +**Height relationship:** slightly shorter than Bruno, much wider than Milo + +### Locked visual identity + +- Deep charcoal-brown fur. +- Lighter gray-brown muzzle and inner ears. +- Small rounded ears. +- Steel-blue eyes. +- Burgundy knit vest over a pale blue dress shirt. +- Dark navy trousers. +- Rectangular black reading glasses. +- Red risk notebook and red pen. + +### Personality + +- Cautious, analytical, dry, easily stressed. +- Assumes leverage is hiding somewhere. +- Usually correct about risk and wrong about timing. +- Secretly cares about keeping the team safe. + +### Voice and dialogue + +- Low, controlled voice that cracks under extreme stress. +- Deadpan questions and reluctant warnings. +- Example rhythm: “Show me the volume.” / “This is not confirmation.” + +### Motion language + +- Small guarded gestures. +- Adjusts glasses. +- Clutches notebook. +- Freezes before panic. +- Ears react subtly to alarming sounds. + +### Comedy weakness + +Catastrophizes every move and misses the celebration. + +## 3.3 Milo Cat + +**Story role:** strategist, observer, and final-button character +**Species:** anthropomorphic orange tabby cat +**Silhouette:** slim, upright, triangular ears and expressive tail +**Apparent age:** mid-to-late 20s +**Height relationship:** shortest of the trio + +### Locked visual identity + +- Burnt-orange tabby fur. +- Darker stripes: three forehead marks, cheek stripes, ringed tail. +- Cream chin, chest patch, and paw tips. +- Emerald-green eyes. +- Dark teal turtleneck. +- Slim black trousers. +- Small silver Bitcoin pin on left chest. +- Matte black coffee mug. +- No glasses, no jacket, no hat. + +### Personality + +- Calm, observant, witty, quietly competitive. +- Often noticed the signal before everyone else. +- Rarely raises his voice. +- Enjoys watching Bruno and Boris prove his point. + +### Voice and dialogue + +- Smooth, understated, slightly amused. +- Delivers the final line with minimal words. +- Example rhythm: “I said that yesterday.” / “Check the weekly.” + +### Motion language + +- Economical movement. +- Slow blinks. +- Controlled sip of coffee. +- Tail communicates hidden emotion. +- Often remains still while the environment becomes chaotic. + +### Comedy weakness + +Smugness. He withholds useful information until the funniest moment. + +## 4. Relationship Engine + +| Pair | Core tension | Reliable comedy | +|---|---|---| +| Bruno + Boris | greed versus risk | Bruno celebrates while Boris searches for invalidation | +| Bruno + Milo | action versus foresight | Bruno discovers what Milo already knew | +| Boris + Milo | anxiety versus calm | Boris overexplains while Milo answers in one line | +| All three | emotional market cycle | hype, fear, then dry factual landing | + +No character is permanently “the smart one” or “the fool.” Their correctness changes with market conditions. + +## 5. Visual Style + +### Style statement + +Premium stylized 3D animation with cinematic lighting, tactile fur, clean silhouettes, expressive faces, and believable office materials. The result should feel polished and original—not photorealistic and not like a children’s preschool cartoon. + +### Shape language + +- Bruno: squares and upward curves. +- Boris: circles and downward weight. +- Milo: triangles and clean vertical lines. +- The environment: rounded rectangles, subtle hexagonal blockchain motifs. + +### Color system + +| Use | Color | Hex target | +|---|---|---| +| Brand night navy | deep blue-black | `#0B1220` | +| Neutral steel | cool gray-blue | `#6F8199` | +| Bull accent | market green | `#16C784` | +| Bear accent | risk red | `#EA3943` | +| Cat accent | teal | `#18A7A0` | +| Warm highlight | amber | `#F5B942` | + +Red and green must not be the only carriers of meaning; use shape, labels, and character reaction for accessibility. + +### Lighting + +- Default: cinematic cool office ambience with warm facial key light. +- Bullish event: green market glow as a secondary light, never full-frame neon wash. +- Bearish event: red alert accents, preserving skin/fur color. +- Milo’s coffee corner: warm amber practical light. + +### Frame priorities + +1. Face and emotion. +2. Clear action silhouette. +3. One important market cue. +4. Background detail. + +## 6. Camera Language + +### Default shot vocabulary + +- Establishing wide: Block Street or The Burrow, 1–2 seconds. +- Character medium shot: primary dialogue. +- Reaction close-up: punchline or emotional reversal. +- Insert shot: chart, alert, coffee mug, notebook. +- Final group composition: factual landing or logo beat. + +### Rules + +- Design for 9:16 from the beginning. +- Keep faces and critical action inside the central safe area. +- One dominant camera action per shot. +- Prefer push-in, short pan, rack focus, or controlled handheld reaction. +- Avoid continuous orbiting, extreme lens distortion, and purposeless camera shake. +- Cut on action or reaction; every shot must add new information. + +## 7. Writing Rules + +### Episode structure + +1. **Hook (0–2 s):** visible disruption or surprising line. +2. **Conflict (2–10 s):** two characters interpret the event differently. +3. **Escalation (10–18 s):** market action becomes physical or socially awkward. +4. **Punchline (18–24 s):** the third character reframes the moment. +5. **Fact card / CTA (last 2–4 s):** concise factual takeaway and optional question. + +### Dialogue + +- English dialogue first; Traditional Chinese subtitle below. +- Prefer one sentence per shot. +- Lines should normally stay under eight words. +- Avoid exposition such as “As you know, Bitcoin is…” +- Let screens or final caption carry exact numbers. +- Use current memes sparingly; the joke must still work after the meme fades. + +### Financial integrity + +- Distinguish confirmed facts from interpretations. +- Include timestamp/source metadata in the EpisodePackage, not spoken dialogue. +- Avoid “guaranteed,” “risk-free,” and direct buy/sell commands. +- When future price direction is discussed, label it as opinion, scenario, or character belief. +- Final caption may include: “For education and entertainment, not financial advice.” + +## 8. Humor System + +Preferred mechanisms: + +- personality collision; +- visual metaphor for a market event; +- delayed reaction; +- rule of three; +- confident statement immediately contradicted by the chart; +- Milo’s understated final line; +- recurring prop jokes. + +Avoid: + +- random meme stacking; +- humiliating a real individual; +- jokes based on protected traits; +- cruelty to animals; +- excessive screaming; +- references that require long explanation; +- copying a recognizable franchise’s characters or signature visual style. + +## 9. Continuity and Generation Rules + +### Character consistency priority + +The generation stack must preserve, in order: + +1. species and body proportions; +2. fur color and markings; +3. face and eye color; +4. clothing colors and signature props; +5. relative height; +6. scene lighting and rendering style. + +### Required reference package per character + +- front neutral portrait; +- left and right three-quarter views; +- full-body front and side; +- six-expression sheet; +- color palette; +- signature prop; +- immutable character description; +- approved negative prompt. + +### Never allow + +- duplicate characters unless scripted; +- extra limbs, fingers, horns, ears, or tails; +- human hands replacing paws/hooves without a defined design; +- clothing changes between adjacent shots; +- changing eye color or fur markings; +- readable AI-generated financial text used as final footage; +- provider logos or watermarks; +- unintended real-company branding. + +## 10. Prompt Style Guide + +Every image or video prompt must be assembled from explicit blocks: + +1. **Format:** aspect ratio, duration, frame rate, resolution target. +2. **World anchor:** Block Street and The Burrow. +3. **Style anchor:** the locked visual style statement. +4. **Character anchor:** exact immutable character description. +5. **Shot intent:** narrative purpose and emotional beat. +6. **Composition:** subject placement, foreground/background. +7. **Action:** one clear action with beginning and end. +8. **Camera:** framing, lens feeling, and one movement. +9. **Lighting:** base ambience and market-event accent. +10. **Continuity:** reference image, wardrobe, props, screen direction. +11. **Exclusions:** anatomy, identity drift, text, logos, camera defects. + +Prompts must not rely on character names alone. “Bruno Bull” is metadata; the full locked visual identity must be injected or attached as a reference. + +## 11. Production Quality Gate + +A shot fails review if any critical item is false: + +- The correct character is immediately recognizable. +- Fur, eyes, wardrobe, and props match the Bible. +- The action reads without dialogue. +- The shot advances story or delivers a reaction. +- Camera movement is controlled. +- No anatomy or identity defect distracts the viewer. +- Important text is added in post, not trusted to generation. +- The shot connects spatially and emotionally to adjacent shots. +- The market claim is consistent with the approved source package. +- The frame remains legible on a phone. + +## 12. First-Episode Baseline + +### Working title + +**BTC Breaks Out — Bruno Celebrates Too Early** + +### Core event + +A confirmed Bitcoin breakout or new all-time high supplied by the Topic Engine at production time. + +### Cast + +- Bruno: drives the hook and celebration. +- Boris: demands confirmation and represents risk. +- Milo: delivers the final dry punchline. + +### Emotional arc + +`surprise → euphoria → doubt → renewed chaos → calm punchline` + +### Four-shot baseline + +1. Bruno bursts into The Burrow as the BTC alert flashes. +2. Boris checks volume and warns that the candle is not closed. +3. The chart surges again; Bruno’s celebration knocks papers into the air. +4. Milo sips coffee and reveals he set the alert yesterday; final factual card. + +## 13. Governance + +### Locked in v1 + +- Block Street and The Burrow. +- Bruno, Boris, and Milo as the core trio. +- Character visual identities. +- Premium stylized 3D direction. +- English dialogue with Traditional Chinese subtitles. +- Real event, character conflict, visual escalation, factual landing. +- No direct financial advice. + +### May vary by episode + +- market topic; +- supporting characters; +- props and wardrobe accessories that do not replace signature clothing; +- time of day; +- camera treatment; +- joke mechanism; +- runtime from 15 to 30 seconds. + +### Change control + +Any permanent change to a locked field requires: + +1. a new Bible version; +2. updated character reference assets; +3. a prompt-regression test; +4. review of at least one old episode using the new configuration. + diff --git a/docs/crypto-animal-studio/bible-v1-implementation-gap-report.md b/docs/crypto-animal-studio/bible-v1-implementation-gap-report.md new file mode 100644 index 00000000..905fe467 --- /dev/null +++ b/docs/crypto-animal-studio/bible-v1-implementation-gap-report.md @@ -0,0 +1,249 @@ +# Bible v1 — Implementation Gap Report + +Status: **Non-implementation analysis.** This document records the distance between the +current codebase and [Crypto Animal Bible v1](Crypto_Animal_Bible_v1.md), as ratified by +[ADR-015](../adr/ADR-015-crypto-animal-bible-v1-canon.md). It changes no code, schema, tests, +or samples, and proposes no unapproved work. + +Baseline at time of writing: Sprint 4 complete (CAS suite **68 passing**), EpisodePackage v1 +unchanged, Prompt Builder v0 (deterministic, mock providers only). + +## Classification legend + +| Class | Meaning | +|---|---| +| **A — Immediate documentation/governance** | Resolved by writing/deciding; no code. | +| **B — Sprint 4A compatibility** | Non-destructive alignment of constants, fixtures, docs. | +| **C — First Real Video requirement** | Must exist before a canonical episode can be produced. | +| **D — Prompt Builder v1 requirement** | Belongs to the scoped prompt rebuild. | +| **E — Future schema proposal** | Needs an approved EpisodePackage v1.1/v2 + ADR. | +| **F — Human quality review** | Judgement-based; not automatable. | + +"Blocks Sprint 4A" = would prevent the non-destructive compatibility step from completing. +Sprint 4A is assumed to be: align constants/fixtures/docs to canon **without** deleting legacy +values, changing the schema, or altering pipeline behavior. + +--- + +## G-01 · Launch cast conflict (canonical trio vs legacy six) + +- **Class:** B — Sprint 4A compatibility (enforcement lands in C/D) +- **Current state:** `RECURRING_CHARACTER_KEYS = {bull, bear, fox, hammy, monkey, walter}` in + `backend/app/crypto_animal_studio/domain/episode_package.py`; docs and the demo sample use + Bull / Bear / Walter. +- **Canonical target:** Bruno Bull, Boris Bear, Milo Cat as the launch trio (Bible §3); legacy + keys retained as technical compatibility values only (ADR-015 §2.5). +- **Blocks Sprint 4A:** No — the constant is a non-enforcing reference; legacy values stay. +- **Proposed sprint:** 4A (add canonical trio alongside legacy, documented as such); canonical + enforcement in Prompt Builder v1 / First Real Video. +- **Risk:** Low. Additive only. Risk is *ambiguity* if both sets are listed without labelling + which is canonical — labelling is mandatory. + +## G-02 · "Walter substituted for Milo" hazard + +- **Class:** A — Immediate documentation/governance +- **Current state:** No rule prevented reusing Walter as the deadpan closer; the demo sample + casts Walter in exactly Milo's structural role. +- **Canonical target:** Walter must never be substituted for Milo in new canonical content + (ADR-015 §2.5). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Recorded now (ADR-015); mechanical enforcement with Prompt Builder v1. +- **Risk:** Low technically, high creatively if ignored — it would ship an off-canon character. + +## G-03 · Episode runtime (45s fixture vs 15–30s canon) + +- **Class:** B (fixture) / C (production rule) +- **Current state:** `samples/cas/demo_episode.json` declares `target_duration_seconds: 45` + with shots summing to 39s; no runtime validation exists. +- **Canonical target:** 15–30 seconds (Bible header, §13). +- **Blocks Sprint 4A:** No — the sample is a technical fixture for pipeline tests, and Sprint 4 + tests assert pipeline behavior, not runtime canon. +- **Proposed sprint:** 4A may add a *canonical* sample (without deleting the legacy fixture); + runtime validation belongs to First Real Video / quality gate. +- **Risk:** Medium if the legacy fixture is edited in place — Sprint 4 tests and the ledger + payload hash depend on its exact content. Prefer adding a new canonical sample. + +## G-04 · 9:16 format not represented or enforced + +- **Class:** D + E (representation) / C (production) +- **Current state:** `creative_direction.format` is free text (`"short_form_vertical"`); no + aspect-ratio, fps, or resolution field exists; nothing validates them. +- **Canonical target:** 9:16 designed-in from the start, plus an explicit format block in every + prompt (Bible §6, §10.1). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Prompt Builder v1 can emit format from constants; first-class schema + fields require an approved v1.1/v2 proposal. +- **Risk:** Low now; medium later if prompts and schema disagree about format authority. + +## G-05 · Traditional Chinese subtitles have no schema representation + +- **Class:** E — Future schema proposal +- **Current state:** EpisodePackage v1 has a single `language` field; `DialogueLine` carries + `text` only — no translation/subtitle field. +- **Canonical target:** English dialogue with Traditional Chinese subtitles for publishable + episodes (Bible §7, ADR-015 §2.3). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Approved EpisodePackage **v1.1/v2** proposal with its own ADR. Must not + be bolted on ad hoc (ADR-015 §2.8). +- **Risk:** Medium — a schema change touches the contract, importer mapping, and the manifest; + it needs versioning discipline and backward compatibility for v1 packages. + +## G-06 · Fact card / CTA beat has no dedicated representation + +- **Class:** E — Future schema proposal (short-term workaround possible) +- **Current state:** No field for the closing factual card or the + "For education and entertainment, not financial advice" caption; it could only live inside a + shot's free-text fields. +- **Canonical target:** A required 2–4s factual landing beat (Bible §7). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Same v1.1/v2 proposal as G-05; interim convention (final shot carries it) + may be documented without a schema change. +- **Risk:** Low-medium; mainly a traceability gap (the disclaimer should be machine-checkable). + +## G-07 · Prompt Builder v0 does not satisfy Bible §10 + +- **Class:** D — Prompt Builder v1 requirement +- **Current state:** `prompt_builder.py` emits a single flat string + (`style | scene | characters | action | camera | image_prompt`) and a generic + `BASE_NEGATIVE_PROMPT`; it injects **display names only**. +- **Canonical target:** 11 explicit blocks (format, world anchor, style anchor, character + anchor, shot intent, composition, action, camera, lighting, continuity, exclusions), with the + rule that names alone are insufficient — full locked identities or reference images must be + injected (Bible §10). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** **Prompt Builder v1** (separate, scoped; not authorized by ADR-015). +- **Risk:** Low to implement additively (v0 can remain for existing tests); high creative risk + if real generation runs on v0 prompts. + +## G-08 · Negative prompts are generic, not Bible-derived + +- **Class:** D — Prompt Builder v1 requirement +- **Current state:** One generic quality-oriented negative string. +- **Canonical target:** Exclusions covering Bible §9 "Never allow": duplicate characters, extra + limbs/fingers/horns/ears/tails, human hands replacing paws/hooves, clothing changes between + adjacent shots, eye-colour/marking drift, readable AI-generated financial text as final + footage, provider logos/watermarks, unintended real-company branding. +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Prompt Builder v1, plus per-character "approved negative prompt" assets. +- **Risk:** Low technically; directly determines visual defect rate in real generation. + +## G-09 · Character reference package incomplete + +- **Class:** C — First Real Video requirement +- **Current state:** Jellyfish `AssetViewAngle` supports FRONT/LEFT/RIGHT/BACK/THREE_QUARTER/ + TOP/DETAIL, which covers most required views; there is **no** representation for a + six-expression sheet, an immutable character description, or an approved negative prompt. +- **Canonical target:** Per-character package: front portrait, L/R three-quarter, full-body + front+side, six-expression sheet, palette, signature prop, immutable description, approved + negative prompt (Bible §9). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** First Real Video preparation (asset production) + Prompt Builder v1 + (consumption). Storage of description/negative-prompt may need the v1.1/v2 proposal. +- **Risk:** Medium — this is the main lever for character consistency; missing references are + the most likely cause of identity drift. + +## G-10 · Style anchors (palette, shape language, lighting) unencoded + +- **Class:** D — Prompt Builder v1 requirement +- **Current state:** No constants anywhere for the locked palette (`#0B1220`, `#6F8199`, + `#16C784`, `#EA3943`, `#18A7A0`, `#F5B942`), shape language, or lighting rules. +- **Canonical target:** Style anchor block injected into every prompt (Bible §5, §10.3). +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Prompt Builder v1 (CAS-local style constants, mirroring the Bible). +- **Risk:** Low; note the accessibility rule (red/green must not be the sole carrier of meaning) + must survive into prompts and review. + +## G-11 · Camera rules unenforced + +- **Class:** D (prompt) + F (review) +- **Current state:** `CameraSpec` carries shot_type/angle/movement enums that map cleanly to + Jellyfish; nothing enforces "one dominant camera action", safe-area framing, or the + avoid-list (continuous orbiting, extreme distortion, purposeless shake). +- **Canonical target:** Bible §6 shot vocabulary and rules. +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Prompt Builder v1 for prompt-side constraints; human review for the rest. +- **Risk:** Low. The existing enum surface is already Bible-compatible. + +## G-12 · Financial-integrity language rules unchecked + +- **Class:** F (review) with an automatable subset +- **Current state:** No lint for prohibited phrasing; the importer/pipeline never inspects + dialogue for "guaranteed", "risk-free", or buy/sell commands. +- **Canonical target:** Bible §7 financial integrity; predictions labelled as opinion/scenario/ + belief; source+timestamp in metadata, not spoken dialogue. +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** A deterministic phrase check is feasible alongside the quality gate; + nuance (is this framed as opinion?) stays human. +- **Risk:** Low technically, high compliance risk if unaddressed before publishing. + +## G-13 · Quality gate: automated vs human split undefined + +- **Class:** F — Human quality review (+ partial automation) +- **Current state:** Bible §11 lists 10 pass/fail criteria; none are implemented, and the split + is undefined. +- **Canonical target:** Every criterion labelled as an automated check or a human review check + (ADR-015 §2.10). Plausibly automatable: text-added-in-post, market claim vs source package, + camera-action count, frame legibility proxies. Human: character recognizability, comedic + function, spatial/emotional continuity, defect distraction. +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Quality-gate design, after Prompt Builder v1. +- **Risk:** Reputational if "quality gate implemented" is claimed without the distinction. + +## G-14 · Production output is mock-only (`.txt`, no real 9:16 video) + +- **Class:** C — First Real Video requirement +- **Current state:** Sprint 4 mock providers write deterministic `.txt` placeholders; no real + image/video/voice provider, no FFmpeg, synchronous execution, local filesystem storage. +- **Canonical target:** Real 9:16 video with the canonical trio, subtitles, and a fact card. +- **Blocks Sprint 4A:** No — mock-only is Sprint 4's accepted design (ADR-014). +- **Proposed sprint:** Sprint 5 (real providers behind the existing adapter interfaces), + then task-center execution and S3/RustFS storage. +- **Risk:** Medium — must reuse Jellyfish `Provider`/`Model`/`ModelSettings` and the existing + task system; a second provider or task system is prohibited. + +## G-15 · Bible location vs referenced path + +- **Class:** A — Immediate documentation/governance +- **Current state:** The Bible lives at `docs/crypto-animal-studio/Crypto_Animal_Bible_v1.md`; + it has been referenced in conversation as `docs/cas/...`. +- **Canonical target:** One agreed path, referenced consistently by ADRs and docs. +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Decide now; ADR-015 currently cites the actual path. +- **Risk:** Low, but broken references are a recurring documentation defect. + +## G-16 · Governance record for canon + +- **Class:** A — Immediate documentation/governance +- **Current state:** Resolved — [ADR-015](../adr/ADR-015-crypto-animal-bible-v1-canon.md) + records Bible v1 as canon, the launch trio, runtime/format, and the legacy compatibility + policy. +- **Canonical target:** Same. +- **Blocks Sprint 4A:** No. +- **Proposed sprint:** Complete. +- **Risk:** None. + +--- + +## Summary by class + +| Class | Items | +|---|---| +| A — Immediate documentation/governance | G-02, G-15, G-16 | +| B — Sprint 4A compatibility | G-01, G-03 (fixture aspect) | +| C — First Real Video requirement | G-03 (rule), G-04 (production), G-09, G-14 | +| D — Prompt Builder v1 requirement | G-04 (emit), G-07, G-08, G-10, G-11 | +| E — Future schema proposal | G-05, G-06 | +| F — Human quality review | G-11 (review side), G-12, G-13 | + +## Blocking analysis + +**Blocks the first real canonical video:** G-01 (canonical trio actually cast), G-03 (15–30s), +G-04 (9:16), G-05 (Traditional Chinese subtitles, for a *publishable* episode), G-07 + G-08 + +G-10 (Bible-conformant prompts with full identities and exclusions), G-09 (character reference +assets), G-14 (real providers). + +**Does not block Sprint 4A:** every item above. Sprint 4A is a non-destructive alignment of +constants, fixtures, and documentation; it neither produces video nor changes the schema, so no +gap in this report prevents it from completing. The only real Sprint 4A hazard is **editing the +existing demo fixture in place** (G-03), which would perturb Sprint 4 tests and the ledger +payload hash — add a canonical sample instead. From c064d49302579d6e64d2aec815fe796f51bcefe2 Mon Sep 17 00:00:00 2001 From: shiuyu Date: Wed, 29 Jul 2026 19:25:14 +0800 Subject: [PATCH 3/5] feat(cas): complete production workspace vertical slice --- backend/app/api/v1/routes/studio/files.py | 16 +- backend/app/core/db.py | 2 + .../core/integrations/video_capabilities.py | 65 + .../app/crypto_animal_studio/api/__init__.py | 3 +- .../api/import_episode.py | 58 +- .../crypto_animal_studio/api/production.py | 138 + .../application/import_episode.py | 85 +- .../application/import_result.py | 19 +- .../application/import_tasks.py | 254 + .../application/parsing.py | 79 + .../application/subtitle_artifact.py | 253 + .../application/validation.py | 575 ++ .../domain/episode_package.py | 7 +- .../domain/market_facts.py | 124 + .../domain/provider_safety.py | 180 + .../crypto_animal_studio/domain/runtime.py | 87 + .../app/crypto_animal_studio/domain/webvtt.py | 101 + .../production/__init__.py | 6 + .../production/artifact_manager.py | 233 + .../crypto_animal_studio/production/cli.py | 87 + .../crypto_animal_studio/production/enums.py | 63 + .../crypto_animal_studio/production/models.py | 100 + .../production/orchestrator.py | 383 + .../production/prompt_builder.py | 106 + .../production/providers/__init__.py | 33 + .../production/providers/base.py | 95 + .../production/providers/mock.py | 120 + .../schemas/episode_package.py | 331 +- .../schemas/import_request.py | 26 +- .../schemas/production.py | 99 + backend/app/models/types.py | 2 + backend/app/schemas/studio/files.py | 1 + backend/app/services/film/generated_video.py | 3 +- backend/app/services/studio/action_beats.py | 20 +- backend/app/services/studio/file_usages.py | 18 +- .../studio/generation/frame/derive_preview.py | 8 + backend/app/services/worker/task_registry.py | 13 + backend/sql/010-add-cas-production-tables.sql | 71 + .../integrations/test_video_capabilities.py | 39 +- .../tests/fixtures/cas/ep001_shaped_v11.json | 281 + backend/tests/support/fake_storage.py | 62 + backend/tests/test_api_response_envelopes.py | 4 +- backend/tests/test_cas_api_v11_ingestion.py | 229 + .../tests/test_cas_ep001_vertical_slice.py | 595 ++ backend/tests/test_cas_episode_package_v11.py | 686 ++ backend/tests/test_cas_production.py | 349 + backend/tests/test_cas_production_api_cli.py | 180 + backend/tests/test_cas_subtitle_artifact.py | 514 ++ backend/tests/test_entities_api_responses.py | 5 +- .../test_entity_existence_api_responses.py | 2 + backend/tests/test_files_api_responses.py | 10 +- backend/tests/test_files_scope_filters.py | 199 + ...test_shot_character_links_api_responses.py | 1 + .../test_shot_subresource_api_responses.py | 32 + backend/tests/test_skills_integration.py | 2 +- docs/adr/ADR-014-cas-production-pipeline.md | 110 + docs/adr/ADR-016-episode-package-v1-1.md | 266 + docs/cas-production-mvp.md | 117 + .../EpisodePackage-v1.1-proposal.md | 810 ++ ...c-breaks-out-bruno-celebrates-too-early.md | 472 ++ docs/crypto-animal-studio/import-mapper-v1.md | 72 + docs/implementation-log.md | 148 + front/openapi.json | 2 +- front/package.json | 9 +- front/pnpm-lock.yaml | 6593 ++++++++++------- front/src/App.tsx | 2 + .../aiStudio/cas/Ep001Workspace.test.tsx | 365 + .../src/pages/aiStudio/cas/Ep001Workspace.tsx | 495 ++ front/src/pages/aiStudio/cas/webvtt.test.ts | 79 + front/src/pages/aiStudio/cas/webvtt.ts | 81 + front/src/services/casWorkspaceApi.ts | 188 + front/src/services/generated/index.ts | 49 + .../services/generated/models/ActorAsset.ts | 22 + .../ApiResponse_CasImportTaskAccepted_.ts | 24 + .../models/ApiResponse_ImportResult_.ts | 24 + .../models/ApiResponse_ProductionJobView_.ts | 24 + ...iResponse_list_ProductionArtifactView__.ts | 24 + .../services/generated/models/AssetLibrary.ts | 30 + .../services/generated/models/CameraSpec.ts | 30 + .../generated/models/CasCameraAngle.ts | 8 + .../generated/models/CasCameraMovement.ts | 8 + .../generated/models/CasImportTaskAccepted.ts | 34 + .../services/generated/models/CasShotType.ts | 8 + .../generated/models/CharacterSpec.ts | 45 + .../services/generated/models/CostumeAsset.ts | 22 + .../models/CreateProductionJobRequest.ts | 24 + .../generated/models/CreativeDirection.ts | 34 + .../src/services/generated/models/DataLock.ts | 18 + .../services/generated/models/DialogueLine.ts | 29 + .../generated/models/EpisodeMetadata.ts | 30 + .../generated/models/EpisodePackage.ts | 64 + .../generated/models/EpisodePackageV11.ts | 90 + .../src/services/generated/models/FactCard.ts | 27 + .../generated/models/FactCardLocalizedCopy.ts | 26 + .../services/generated/models/FileTypeEnum.ts | 2 +- .../services/generated/models/ImportCounts.ts | 20 + .../generated/models/ImportEpisodeRequest.ts | 28 + .../services/generated/models/ImportResult.ts | 55 + .../services/generated/models/Localization.ts | 23 + .../services/generated/models/MarketData.ts | 67 + .../services/generated/models/NewsSource.ts | 36 + .../services/generated/models/OutputSpec.ts | 43 + .../generated/models/OverlayLocalizedText.ts | 18 + .../generated/models/PostProduction.ts | 15 + .../generated/models/PostProductionOverlay.ts | 43 + .../models/ProductionArtifactView.ts | 19 + .../generated/models/ProductionJobView.ts | 25 + .../generated/models/ProductionShotView.ts | 17 + .../services/generated/models/PropAsset.ts | 22 + .../generated/models/ReferenceAsset.ts | 38 + .../services/generated/models/References.ts | 31 + .../generated/models/RegenerationFallback.ts | 19 + .../models/RetryProductionJobRequest.ts | 20 + .../src/services/generated/models/SafeArea.ts | 18 + .../services/generated/models/SceneAsset.ts | 22 + front/src/services/generated/models/Shot.ts | 85 + .../src/services/generated/models/ShotV11.ts | 104 + .../generated/models/SubtitleArtifact.ts | 34 + .../services/generated/models/SubtitleCue.ts | 34 + .../generated/models/SubtitleTrack.ts | 27 + .../CryptoAnimalStudioProductionService.ts | 104 + .../services/CryptoAnimalStudioService.ts | 175 + .../generated/services/StudioFilesService.ts | 12 + front/src/setupTests.ts | 45 + front/vitest.config.ts | 14 + samples/cas/demo_episode.json | 169 + samples/cas/ep001_btc_breakout.json | 555 ++ 127 files changed, 16796 insertions(+), 2700 deletions(-) create mode 100644 backend/app/crypto_animal_studio/api/production.py create mode 100644 backend/app/crypto_animal_studio/application/import_tasks.py create mode 100644 backend/app/crypto_animal_studio/application/parsing.py create mode 100644 backend/app/crypto_animal_studio/application/subtitle_artifact.py create mode 100644 backend/app/crypto_animal_studio/application/validation.py create mode 100644 backend/app/crypto_animal_studio/domain/market_facts.py create mode 100644 backend/app/crypto_animal_studio/domain/provider_safety.py create mode 100644 backend/app/crypto_animal_studio/domain/runtime.py create mode 100644 backend/app/crypto_animal_studio/domain/webvtt.py create mode 100644 backend/app/crypto_animal_studio/production/__init__.py create mode 100644 backend/app/crypto_animal_studio/production/artifact_manager.py create mode 100644 backend/app/crypto_animal_studio/production/cli.py create mode 100644 backend/app/crypto_animal_studio/production/enums.py create mode 100644 backend/app/crypto_animal_studio/production/models.py create mode 100644 backend/app/crypto_animal_studio/production/orchestrator.py create mode 100644 backend/app/crypto_animal_studio/production/prompt_builder.py create mode 100644 backend/app/crypto_animal_studio/production/providers/__init__.py create mode 100644 backend/app/crypto_animal_studio/production/providers/base.py create mode 100644 backend/app/crypto_animal_studio/production/providers/mock.py create mode 100644 backend/app/crypto_animal_studio/schemas/production.py create mode 100644 backend/sql/010-add-cas-production-tables.sql create mode 100644 backend/tests/fixtures/cas/ep001_shaped_v11.json create mode 100644 backend/tests/support/fake_storage.py create mode 100644 backend/tests/test_cas_api_v11_ingestion.py create mode 100644 backend/tests/test_cas_ep001_vertical_slice.py create mode 100644 backend/tests/test_cas_episode_package_v11.py create mode 100644 backend/tests/test_cas_production.py create mode 100644 backend/tests/test_cas_production_api_cli.py create mode 100644 backend/tests/test_cas_subtitle_artifact.py create mode 100644 backend/tests/test_files_scope_filters.py create mode 100644 docs/adr/ADR-014-cas-production-pipeline.md create mode 100644 docs/adr/ADR-016-episode-package-v1-1.md create mode 100644 docs/cas-production-mvp.md create mode 100644 docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md create mode 100644 docs/crypto-animal-studio/episodes/EP001-btc-breaks-out-bruno-celebrates-too-early.md create mode 100644 front/src/pages/aiStudio/cas/Ep001Workspace.test.tsx create mode 100644 front/src/pages/aiStudio/cas/Ep001Workspace.tsx create mode 100644 front/src/pages/aiStudio/cas/webvtt.test.ts create mode 100644 front/src/pages/aiStudio/cas/webvtt.ts create mode 100644 front/src/services/casWorkspaceApi.ts create mode 100644 front/src/services/generated/models/ActorAsset.ts create mode 100644 front/src/services/generated/models/ApiResponse_CasImportTaskAccepted_.ts create mode 100644 front/src/services/generated/models/ApiResponse_ImportResult_.ts create mode 100644 front/src/services/generated/models/ApiResponse_ProductionJobView_.ts create mode 100644 front/src/services/generated/models/ApiResponse_list_ProductionArtifactView__.ts create mode 100644 front/src/services/generated/models/AssetLibrary.ts create mode 100644 front/src/services/generated/models/CameraSpec.ts create mode 100644 front/src/services/generated/models/CasCameraAngle.ts create mode 100644 front/src/services/generated/models/CasCameraMovement.ts create mode 100644 front/src/services/generated/models/CasImportTaskAccepted.ts create mode 100644 front/src/services/generated/models/CasShotType.ts create mode 100644 front/src/services/generated/models/CharacterSpec.ts create mode 100644 front/src/services/generated/models/CostumeAsset.ts create mode 100644 front/src/services/generated/models/CreateProductionJobRequest.ts create mode 100644 front/src/services/generated/models/CreativeDirection.ts create mode 100644 front/src/services/generated/models/DataLock.ts create mode 100644 front/src/services/generated/models/DialogueLine.ts create mode 100644 front/src/services/generated/models/EpisodeMetadata.ts create mode 100644 front/src/services/generated/models/EpisodePackage.ts create mode 100644 front/src/services/generated/models/EpisodePackageV11.ts create mode 100644 front/src/services/generated/models/FactCard.ts create mode 100644 front/src/services/generated/models/FactCardLocalizedCopy.ts create mode 100644 front/src/services/generated/models/ImportCounts.ts create mode 100644 front/src/services/generated/models/ImportEpisodeRequest.ts create mode 100644 front/src/services/generated/models/ImportResult.ts create mode 100644 front/src/services/generated/models/Localization.ts create mode 100644 front/src/services/generated/models/MarketData.ts create mode 100644 front/src/services/generated/models/NewsSource.ts create mode 100644 front/src/services/generated/models/OutputSpec.ts create mode 100644 front/src/services/generated/models/OverlayLocalizedText.ts create mode 100644 front/src/services/generated/models/PostProduction.ts create mode 100644 front/src/services/generated/models/PostProductionOverlay.ts create mode 100644 front/src/services/generated/models/ProductionArtifactView.ts create mode 100644 front/src/services/generated/models/ProductionJobView.ts create mode 100644 front/src/services/generated/models/ProductionShotView.ts create mode 100644 front/src/services/generated/models/PropAsset.ts create mode 100644 front/src/services/generated/models/ReferenceAsset.ts create mode 100644 front/src/services/generated/models/References.ts create mode 100644 front/src/services/generated/models/RegenerationFallback.ts create mode 100644 front/src/services/generated/models/RetryProductionJobRequest.ts create mode 100644 front/src/services/generated/models/SafeArea.ts create mode 100644 front/src/services/generated/models/SceneAsset.ts create mode 100644 front/src/services/generated/models/Shot.ts create mode 100644 front/src/services/generated/models/ShotV11.ts create mode 100644 front/src/services/generated/models/SubtitleArtifact.ts create mode 100644 front/src/services/generated/models/SubtitleCue.ts create mode 100644 front/src/services/generated/models/SubtitleTrack.ts create mode 100644 front/src/services/generated/services/CryptoAnimalStudioProductionService.ts create mode 100644 front/src/services/generated/services/CryptoAnimalStudioService.ts create mode 100644 front/src/setupTests.ts create mode 100644 front/vitest.config.ts create mode 100644 samples/cas/demo_episode.json create mode 100644 samples/cas/ep001_btc_breakout.json diff --git a/backend/app/api/v1/routes/studio/files.py b/backend/app/api/v1/routes/studio/files.py index 2e81f77f..3e4b32a1 100644 --- a/backend/app/api/v1/routes/studio/files.py +++ b/backend/app/api/v1/routes/studio/files.py @@ -36,12 +36,22 @@ async def list_files_api( project_id: str | None = Query(None, description="按 file_usages 限定项目;提供后仅返回该项目下有关联记录的文件"), chapter_title: str | None = Query(None, description="章节标题(精确匹配,与 project_id 联用)"), shot_title: str | None = Query(None, description="镜头标题(精确匹配,与 project_id 联用)"), + chapter_id: str | None = Query( + None, description="按 file_usages.chapter_id 精确过滤(与 project_id 联用;比标题稳定)" + ), + usage_kind: str | None = Query( + None, description="按 file_usages.usage_kind 精确过滤,如 subtitle(与 project_id 联用)" + ), ) -> ApiResponse[PaginatedData[FileRead]]: - if chapter_title is not None or shot_title is not None: + scoped = (chapter_title, shot_title, chapter_id, usage_kind) + if any(value is not None for value in scoped): if not project_id: raise HTTPException( status_code=400, - detail="project_id is required when chapter_title or shot_title is set", + detail=( + "project_id is required when chapter_title, shot_title, " + "chapter_id or usage_kind is set" + ), ) if project_id is not None: @@ -50,6 +60,8 @@ async def list_files_api( project_id=project_id, chapter_title=chapter_title, shot_title=shot_title, + chapter_id=chapter_id, + usage_kind=usage_kind, q=q, order=order, is_desc=is_desc, diff --git a/backend/app/core/db.py b/backend/app/core/db.py index d7fedd6b..290e01e2 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -68,6 +68,8 @@ async def init_db() -> None: import app.models.task_links # noqa: F401 # CAS 边界模块的导入台账表(幂等记账);导入以注册到 Base.metadata。 import app.crypto_animal_studio.domain.import_ledger # noqa: F401 + # CAS 生产流水线表(任务/镜头/产物)。 + import app.crypto_animal_studio.production.models # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/core/integrations/video_capabilities.py b/backend/app/core/integrations/video_capabilities.py index 20233ccd..3b09d7e5 100644 --- a/backend/app/core/integrations/video_capabilities.py +++ b/backend/app/core/integrations/video_capabilities.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from math import gcd from app.core.contracts.provider import ProviderKey from app.core.contracts.video_generation import VideoGenerationInput, VideoRatio @@ -18,6 +19,70 @@ } +def _parse_dimensions(candidate: str) -> tuple[int, int] | None: + """把 ``WIDTHxHEIGHT`` 解析为正整数尺寸对;格式非法或含非正数时返回 ``None``。 + + 参数: + candidate: 已去除首尾空白的候选字符串;分隔符 ``x`` 大小写不敏感, + 允许尺寸与分隔符之间存在空格(如 ``"1920 x 1080"``)。 + 返回: + ``(width, height)``,或 ``None``。 + """ + normalized = candidate.lower().replace(" ", "") + width_text, separator, height_text = normalized.partition("x") + if not separator: + return None + # ``isdigit`` 同时排除负号、小数点与非数字内容。 + if not (width_text.isdigit() and height_text.isdigit()): + return None + width = int(width_text) + height = int(height_text) + if width <= 0 or height <= 0: + return None + return width, height + + +def infer_ratio_from_size(value: str | None) -> str | None: + """从「比例字符串」或「分辨率字符串」推断受支持的宽高比。 + + 做什么: + - 直接接受已受支持的比例字符串,如 ``"16:9"``; + - 接受 ``WIDTHxHEIGHT`` 形式的分辨率,如 ``"1920x1080"``,用最大公约数约简后得到比例; + - 仅当结果落在 :data:`ALLOWED_RATIOS` 内才返回,否则返回 ``None``。 + + 为什么存在: + - 供应商参数既可能给比例也可能给具体尺寸,上层需要一个统一的归一化入口, + 避免在各处重复解析尺寸字符串。 + + 参数: + value: 比例(``"9:16"``)或分辨率(``"720x1280"``)字符串;允许首尾空白, + 分隔符 ``x`` 大小写不敏感。 + + 返回: + 归一化后的比例字符串;输入为空白、格式非法、含零或负数尺寸、 + 或约简结果不受支持时返回 ``None``。 + """ + if not isinstance(value, str): + return None + + candidate = value.strip() + if not candidate: + return None + + # 已经是受支持的比例字符串:原样返回。 + if candidate in ALLOWED_RATIOS: + return candidate + + dimensions = _parse_dimensions(candidate) + if dimensions is None: + return None + + width, height = dimensions + divisor = gcd(width, height) + ratio = f"{width // divisor}:{height // divisor}" + return ratio if ratio in ALLOWED_RATIOS else None + + @dataclass(frozen=True, slots=True) class VideoModelCapability: """供应商/模型能力约束。""" diff --git a/backend/app/crypto_animal_studio/api/__init__.py b/backend/app/crypto_animal_studio/api/__init__.py index 6ffd4ac3..d682d711 100644 --- a/backend/app/crypto_animal_studio/api/__init__.py +++ b/backend/app/crypto_animal_studio/api/__init__.py @@ -6,10 +6,11 @@ from fastapi import APIRouter -from app.crypto_animal_studio.api import health, import_episode +from app.crypto_animal_studio.api import health, import_episode, production router = APIRouter() router.include_router(health.router) router.include_router(import_episode.router) +router.include_router(production.router, prefix="/production", tags=["crypto-animal-studio/production"]) __all__ = ["router"] diff --git a/backend/app/crypto_animal_studio/api/import_episode.py b/backend/app/crypto_animal_studio/api/import_episode.py index 77b3eae8..446a3ea9 100644 --- a/backend/app/crypto_animal_studio/api/import_episode.py +++ b/backend/app/crypto_animal_studio/api/import_episode.py @@ -13,13 +13,21 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.crypto_animal_studio.application.import_episode import ( + CasValidationError, EpisodeAlreadyImportedError, IdempotencyConflictError, ProjectNotFoundError, import_episode, ) from app.crypto_animal_studio.application.import_result import ImportResult -from app.crypto_animal_studio.schemas.import_request import ImportEpisodeRequest +from app.crypto_animal_studio.application.import_tasks import ( + CAS_IMPORT_EPISODE_TASK_KIND, + create_cas_import_task, +) +from app.crypto_animal_studio.schemas.import_request import ( + CasImportTaskAccepted, + ImportEpisodeRequest, +) from app.dependencies import get_db from app.schemas.common import ApiResponse, success_response @@ -34,7 +42,8 @@ async def import_episode_endpoint( """导入一个 EpisodePackage 为一个 Jellyfish Chapter(含 Shots 等)。 返回:统一 ``ApiResponse``,data 为 ImportResult。 - 错误:项目不存在→404;幂等冲突/重复导入→409;契约校验失败→422(由 pydantic)。 + 错误:项目不存在→404;幂等冲突/重复导入→409;契约校验失败→422(由 pydantic); + CAS QA 闸门失败→422(零写入)。 """ try: result = await import_episode( @@ -48,4 +57,49 @@ async def import_episode_endpoint( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except (IdempotencyConflictError, EpisodeAlreadyImportedError) as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CasValidationError as exc: + # 与 pydantic 契约校验一致用 422:两者都表示「文档不可接受」,且都零写入。 + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc return success_response(data=result) + + +@router.post("/import/async", response_model=ApiResponse[CasImportTaskAccepted]) +async def import_episode_async_endpoint( + body: ImportEpisodeRequest, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[CasImportTaskAccepted]: + """把导入登记为任务中心的 ``cas_import_episode_package`` 任务并立即返回。 + + 请求体与同步端点完全一致(同一个 ``ImportEpisodeRequest``),因此契约校验行为不变。 + 真正的导入由 ``run_cas_import_task`` 驱动,成功/失败通过既有任务状态查询接口获取。 + + 返回:统一 ``ApiResponse``,data 为任务受理信息(``reused=true`` 表示复用活动任务)。 + """ + created = await create_cas_import_task( + db, + project_id=body.project_id, + # 以 JSON 模式导出:run_args 需要可序列化,且不改变契约本身。 + episode_package=body.episode_package.model_dump(mode="json"), + idempotency_key=body.idempotency_key, + dry_run=body.dry_run, + ) + if not created.reused: + # 与既有异步生成任务同一入队机制(Celery task.execute + task_kind registry)。 + # 延迟导入:避免 api → tasks → services → api 的导入环,与 script 任务写法一致。 + from app.tasks.execute_task import enqueue_task_execution + + # 任务行必须先可见,worker 才能按 task_id 取到它。 + await db.commit() + enqueue_task_execution(created.task_id) + return success_response( + data=CasImportTaskAccepted( + task_id=created.task_id, + status=created.status.value, + reused=created.reused, + task_kind=CAS_IMPORT_EPISODE_TASK_KIND, + relation_type=created.relation_type, + relation_entity_id=created.relation_entity_id, + ) + ) diff --git a/backend/app/crypto_animal_studio/api/production.py b/backend/app/crypto_animal_studio/api/production.py new file mode 100644 index 00000000..3418a926 --- /dev/null +++ b/backend/app/crypto_animal_studio/api/production.py @@ -0,0 +1,138 @@ +"""CAS 生产 API(薄路由)。 + +注册于 ``/api/v1/crypto-animal-studio/production``(沿用仓库 api_v1 前缀与 CAS 挂载点, +不新建独立 FastAPI app)。本冲刺同步执行,不使用 Celery。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.production.enums import ArtifactType +from app.crypto_animal_studio.production.models import CasProductionArtifact, CasProductionJob, CasProductionShot +from app.crypto_animal_studio.production.orchestrator import ( + JobNotFoundError, + PackageMismatchError, + retry_production, + start_production, +) +from app.crypto_animal_studio.production.providers.mock import build_mock_bundle +from app.crypto_animal_studio.schemas.production import ( + CreateProductionJobRequest, + ProductionArtifactView, + ProductionJobView, + ProductionShotView, + RetryProductionJobRequest, +) +from app.dependencies import get_db +from app.schemas.common import ApiResponse, success_response + +router = APIRouter() + + +async def _build_job_view(db: AsyncSession, job: CasProductionJob) -> ProductionJobView: + """把 ORM 任务组装为 API 视图(含镜头、manifest 与成片路径)。""" + shots = list( + (await db.execute(select(CasProductionShot).where(CasProductionShot.job_id == job.id).order_by(CasProductionShot.sequence))) + .scalars() + .all() + ) + artifacts = list((await db.execute(select(CasProductionArtifact).where(CasProductionArtifact.job_id == job.id))).scalars().all()) + manifest = next((a for a in artifacts if a.artifact_type == ArtifactType.manifest.value), None) + final = next((a for a in artifacts if a.artifact_type == ArtifactType.final_video.value), None) + return ProductionJobView( + id=job.id, + project_id=job.project_id, + episode_id=job.episode_id, + status=job.status, + current_stage=job.current_stage, + provider_mode=job.provider_mode, + episode_package_hash=job.episode_package_hash, + output_path=job.output_path, + error_message=job.error_message, + started_at=job.started_at.isoformat() if job.started_at else None, + completed_at=job.completed_at.isoformat() if job.completed_at else None, + shots=[ + ProductionShotView( + id=s.id, + source_shot_id=s.source_shot_id, + sequence=s.sequence, + status=s.status, + current_stage=s.current_stage, + duration_seconds=s.duration_seconds, + error_message=s.error_message, + ) + for s in shots + ], + manifest_path=manifest.file_path if manifest else None, + final_output=final.file_path if final else None, + ) + + +@router.post("/jobs", response_model=ApiResponse[ProductionJobView]) +async def create_production_job(body: CreateProductionJobRequest, db: AsyncSession = Depends(get_db)) -> ApiResponse[ProductionJobView]: + """创建并同步执行一次生产(每次调用创建新任务)。""" + job = await start_production( + db, project_id=body.project_id, package=body.episode_package, providers=build_mock_bundle(), provider_mode=body.mode + ) + return success_response(data=await _build_job_view(db, job)) + + +@router.get("/jobs/{job_id}", response_model=ApiResponse[ProductionJobView]) +async def get_production_job(job_id: str, db: AsyncSession = Depends(get_db)) -> ApiResponse[ProductionJobView]: + """查询生产任务状态。""" + job = await db.get(CasProductionJob, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"production job not found: {job_id}") + return success_response(data=await _build_job_view(db, job)) + + +@router.get("/jobs/{job_id}/artifacts", response_model=ApiResponse[list[ProductionArtifactView]]) +async def list_production_artifacts(job_id: str, db: AsyncSession = Depends(get_db)) -> ApiResponse[list[ProductionArtifactView]]: + """列出任务的全部产物。""" + job = await db.get(CasProductionJob, job_id) + if job is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"production job not found: {job_id}") + rows = list( + ( + await db.execute( + select(CasProductionArtifact) + .where(CasProductionArtifact.job_id == job_id) + .order_by(CasProductionArtifact.artifact_type, CasProductionArtifact.file_path) + ) + ) + .scalars() + .all() + ) + return success_response( + data=[ + ProductionArtifactView( + id=a.id, + production_shot_id=a.production_shot_id, + artifact_type=a.artifact_type, + stage=a.stage, + provider=a.provider, + provider_model=a.provider_model, + file_path=a.file_path, + mime_type=a.mime_type, + checksum=a.checksum, + ) + for a in rows + ] + ) + + +@router.post("/jobs/{job_id}/retry", response_model=ApiResponse[ProductionJobView]) +async def retry_production_job( + job_id: str, body: RetryProductionJobRequest, db: AsyncSession = Depends(get_db) +) -> ApiResponse[ProductionJobView]: + """从失败阶段重试(复用更早的有效产物)。""" + try: + job = await retry_production(db, job_id=job_id, package=body.episode_package, providers=build_mock_bundle()) + except JobNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PackageMismatchError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + return success_response(data=await _build_job_view(db, job)) diff --git a/backend/app/crypto_animal_studio/application/import_episode.py b/backend/app/crypto_animal_studio/application/import_episode.py index 58caacaa..2f81f9c0 100644 --- a/backend/app/crypto_animal_studio/application/import_episode.py +++ b/backend/app/crypto_animal_studio/application/import_episode.py @@ -23,7 +23,21 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.crypto_animal_studio.application.hashing import canonical_payload_hash -from app.crypto_animal_studio.application.import_result import ImportCounts, ImportResult +from app.crypto_animal_studio.application.import_result import ( + ImportCounts, + ImportResult, + SubtitleArtifact, +) +from app.crypto_animal_studio.application.subtitle_artifact import ( + SubtitleArtifactOutcome, + ensure_subtitle_artifacts, + lookup_subtitle_artifacts, +) +from app.crypto_animal_studio.application.validation import ( + ValidationIssue, + ValidationStage, + validate_episode_package, +) from app.crypto_animal_studio.domain import mapping from app.crypto_animal_studio.domain.import_ledger import CasImportLedger from app.crypto_animal_studio.schemas.episode_package import EpisodePackage @@ -66,6 +80,20 @@ class EpisodeAlreadyImportedError(CasImportError): """同一 (project, episode) 已在另一幂等键下导入。""" +class CasValidationError(CasImportError): + """CAS QA 闸门未通过。 + + 在**构造任何实体之前**抛出,因此校验失败绝不会留下部分写入的数据库记录。 + """ + + def __init__(self, stage: ValidationStage, issues: list[ValidationIssue]) -> None: + """记录失败阶段与全部错误项。""" + self.stage = stage + self.issues = issues + summary = "; ".join(f"{item.code} at {item.field_path}" for item in issues) + super().__init__(f"CAS QA gate failed at stage '{stage.value}': {summary}") + + class _EntityResolver: """事务内的资产/角色解析器:复用优先、必要才新建、单事务内不重复。""" @@ -158,6 +186,7 @@ async def import_episode( package: EpisodePackage, idempotency_key: str, dry_run: bool = False, + validation_stage: ValidationStage = ValidationStage.pre_render_data_lock, ) -> ImportResult: """把一个已校验的 EpisodePackage 导入为一个 Jellyfish Chapter(含 Shots 等)。 @@ -167,11 +196,20 @@ async def import_episode( package: 已通过契约校验的 EpisodePackage。 idempotency_key: 幂等键。 dry_run: 为真时执行校验/映射/复用查找/告警但不写库。 + validation_stage: CAS QA 闸门阶段。默认 ``pre_render_data_lock``——导入产出的是 + 将被渲染的生产实体,因此要求事实已锁定;v1 文档不含 v1.1 字段,该阶段对其 + 与 design 等价,故既有 v1 行为不变。 返回: ImportResult 摘要。 异常: - ProjectNotFoundError / IdempotencyConflictError / EpisodeAlreadyImportedError。 + CasValidationError(QA 闸门失败,零写入)/ ProjectNotFoundError / + IdempotencyConflictError / EpisodeAlreadyImportedError。 """ + # --- CAS QA 闸门:先于任何实体构造,确保失败时不产生任何部分写入 --- + qa = validate_episode_package(package, stage=validation_stage) + if not qa.ok: + raise CasValidationError(validation_stage, qa.errors) + payload_hash = canonical_payload_hash(package) project = await db.get(Project, project_id) @@ -201,6 +239,20 @@ async def import_episode( chapter_id=ledger_row.chapter_id, chapter_index=None, warnings=["idempotent replay: returned existing import result"], + # 重放不重建产物,但仍如实报告既有产物(要求 8)。 + subtitle_artifacts=[ + SubtitleArtifact( + file_id=record.file_id, + language_tag=record.language_tag, + storage_key=record.storage_key, + cue_count=record.cue_count, + byte_size=record.byte_size, + created=False, + ) + for record in await lookup_subtitle_artifacts( + db, package=package, project_id=project_id + ) + ], ) # 同 key 不同 payload → 冲突。 raise IdempotencyConflictError( @@ -393,6 +445,33 @@ async def import_episode( ) resolver.created.links += 1 + # --- 字幕产物(WebVTT):放在实体全部就绪之后,尽量缩短「已上传但事务未提交」的窗口 --- + artifacts: list[SubtitleArtifact] = [] + if not dry_run: + outcome = SubtitleArtifactOutcome() + try: + outcome = await ensure_subtitle_artifacts( + db, package=package, project_id=project_id, chapter_id=chapter.id + ) + except Exception: + # 对象存储不参与数据库事务:先补偿删除本次新建的对象,再让异常继续上抛, + # 由调用方回滚事务 → 既不留孤儿对象,也不留部分数据库记录。 + failed = await outcome.rollback_uploads() + if failed: + warnings.append(f"orphaned subtitle objects need manual cleanup: {failed}") + raise + artifacts = [ + SubtitleArtifact( + file_id=record.file_id, + language_tag=record.language_tag, + storage_key=record.storage_key, + cue_count=record.cue_count, + byte_size=record.byte_size, + created=record.created, + ) + for record in outcome.records + ] + if dry_run: # 校验/映射/复用查找/告警均已完成;回滚以确保不写库。 await db.rollback() @@ -439,12 +518,14 @@ async def import_episode( created=resolver.created, reused=resolver.reused, warnings=warnings, + subtitle_artifacts=artifacts, ) __all__ = [ "import_episode", "CasImportError", + "CasValidationError", "ProjectNotFoundError", "IdempotencyConflictError", "EpisodeAlreadyImportedError", diff --git a/backend/app/crypto_animal_studio/application/import_result.py b/backend/app/crypto_animal_studio/application/import_result.py index 7c130825..16407e53 100644 --- a/backend/app/crypto_animal_studio/application/import_result.py +++ b/backend/app/crypto_animal_studio/application/import_result.py @@ -25,6 +25,19 @@ class ImportCounts(BaseModel): links: int = 0 +class SubtitleArtifact(BaseModel): + """一条字幕产物(WebVTT)在导入结果中的表示。""" + + model_config = ConfigDict(extra="forbid") + + file_id: str = Field(..., description="Jellyfish files.id") + language_tag: str = Field(..., description="BCP 47 语言标签,如 zh-Hant") + storage_key: str = Field(..., description="对象存储 key(确定性)") + cue_count: int = Field(..., description="cue 数量") + byte_size: int = Field(..., description="WebVTT 字节数") + created: bool = Field(..., description="true=本次新建;false=复用既有产物并就地更新") + + class ImportResult(BaseModel): """一次导入(或 dry-run / 重放)的结果摘要。""" @@ -42,6 +55,10 @@ class ImportResult(BaseModel): created: ImportCounts = Field(default_factory=ImportCounts, description="本次新建计数") reused: ImportCounts = Field(default_factory=ImportCounts, description="本次复用计数") warnings: list[str] = Field(default_factory=list, description="非阻断告警(不丢弃数据)") + subtitle_artifacts: list[SubtitleArtifact] = Field( + default_factory=list, + description="本次导入生成/复用的字幕产物(WebVTT);v1 文档为空列表", + ) -__all__ = ["ImportResult", "ImportCounts"] +__all__ = ["ImportResult", "ImportCounts", "SubtitleArtifact"] diff --git a/backend/app/crypto_animal_studio/application/import_tasks.py b/backend/app/crypto_animal_studio/application/import_tasks.py new file mode 100644 index 00000000..fb6a806a --- /dev/null +++ b/backend/app/crypto_animal_studio/application/import_tasks.py @@ -0,0 +1,254 @@ +"""CAS EpisodePackage 导入的异步任务集成(最小可用)。 + +本模块是 CAS 与 Jellyfish **任务中心**之间唯一的集成点,刻意做到最小: + +- 复用既有 ``TaskManager`` / ``SqlAlchemyTaskStore`` / ``GenerationTaskLink``, + 与 ``app/services/script_processing_tasks.py`` 的写法保持一致; +- **不新增数据库迁移**:``generation_tasks.task_kind`` 与 + ``generation_task_links.relation_type`` 都是自由字符串列,新增取值无需改表; +- 只做「导入一个已存在且已校验的 EpisodePackage」。新闻抓取、选题、Comedy Engine、 + Character Director、LLM 生成、ComfyUI、视频/语音生成、FFmpeg 合成与发布自动化 + 都**不**属于本步骤。 + +执行语义: +- 创建(``create_cas_import_task``)在调用方的请求事务内完成,只 flush; +- 运行(``run_cas_import_task``)自带独立会话,成功提交、失败回滚并落 failed 状态。 +""" + +from __future__ import annotations + +import hashlib +import logging +from dataclasses import dataclass + +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.crypto_animal_studio.application.import_episode import import_episode +from app.crypto_animal_studio.application.parsing import parse_episode_package +from app.models.task import GenerationTask, GenerationTaskStatus +from app.models.task_links import GenerationTaskLink + +logger = logging.getLogger(__name__) + +#: 任务种类。自由字符串列,无需迁移。 +CAS_IMPORT_EPISODE_TASK_KIND = "cas_import_episode_package" + +#: 业务关联类型(``relation_type`` 为 String(32),本值 18 字符)。 +CAS_EPISODE_IMPORT_RELATION_TYPE = "cas_episode_import" + +_ACTIVE_TASK_STATUSES = ( + GenerationTaskStatus.pending, + GenerationTaskStatus.running, + GenerationTaskStatus.streaming, +) + + +class _CreateOnlyTask: + """仅用于 ``TaskManager.create``,与 script_processing_tasks 的做法一致。""" + + async def run(self, *args: object, **kwargs: object) -> None: + """本任务由 ``run_cas_import_task`` 驱动,这里不执行任何逻辑。""" + return None + + async def status(self) -> dict[str, object]: + """占位实现。""" + return {} + + async def is_done(self) -> bool: + """占位实现。""" + return False + + async def get_result(self) -> object: + """占位实现。""" + return None + + +@dataclass(slots=True) +class CasImportTaskCreateResult: + """创建(或复用)导入任务的结果。""" + + task_id: str + status: TaskStatus + reused: bool + relation_type: str + relation_entity_id: str + + +def episode_relation_entity_id(project_id: str, episode_id: str) -> str: + """把 (project_id, episode_id) 映射为稳定的 64 字符关联键。 + + ``relation_entity_id`` 是 String(64),而 ``project_id`` 本身即可长达 64 字符, + 直接拼接会溢出。这里取 SHA-256 十六进制摘要(恰好 64 字符),既确定性又不越界。 + """ + raw = f"{project_id}:{episode_id}".encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +async def find_active_import_task( + db: AsyncSession, *, project_id: str, episode_id: str +) -> GenerationTask | None: + """查询同一 (project, episode) 下是否已有活动中的导入任务。""" + entity_id = episode_relation_entity_id(project_id, episode_id) + stmt = ( + select(GenerationTask) + .join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id) + .where( + GenerationTaskLink.relation_type == CAS_EPISODE_IMPORT_RELATION_TYPE, + GenerationTaskLink.relation_entity_id == entity_id, + GenerationTask.status.in_(_ACTIVE_TASK_STATUSES), + ) + .limit(1) + ) + return (await db.execute(stmt)).scalars().first() + + +async def create_cas_import_task( + db: AsyncSession, + *, + project_id: str, + episode_package: dict, + idempotency_key: str, + dry_run: bool = False, +) -> CasImportTaskCreateResult: + """创建(或复用)一个 ``cas_import_episode_package`` 任务。 + + 参数: + db: 请求级会话(本函数只 flush,不 commit)。 + project_id: 目标项目(系列/季容器)。 + episode_package: 已校验的 EpisodePackage 原始 dict(v1 或 v1.1)。 + idempotency_key: 导入幂等键,透传给导入服务。 + dry_run: 透传给导入服务。 + 返回: + CasImportTaskCreateResult;``reused=True`` 表示复用了活动中的同类任务。 + """ + episode_id = str(episode_package.get("episode_id") or "") + entity_id = episode_relation_entity_id(project_id, episode_id) + + existing = await find_active_import_task(db, project_id=project_id, episode_id=episode_id) + if existing is not None: + status_value = ( + existing.status.value if hasattr(existing.status, "value") else str(existing.status) + ) + return CasImportTaskCreateResult( + task_id=existing.id, + status=TaskStatus(status_value), + reused=True, + relation_type=CAS_EPISODE_IMPORT_RELATION_TYPE, + relation_entity_id=entity_id, + ) + + store = SqlAlchemyTaskStore(db) + manager = TaskManager(store=store, strategies={}) + task_record = await manager.create( + task=_CreateOnlyTask(), + mode=DeliveryMode.async_polling, + task_kind=CAS_IMPORT_EPISODE_TASK_KIND, + run_args={ + "project_id": project_id, + "episode_package": episode_package, + "idempotency_key": idempotency_key, + "dry_run": dry_run, + }, + ) + db.add( + GenerationTaskLink( + task_id=task_record.id, + resource_type="task_link", + relation_type=CAS_EPISODE_IMPORT_RELATION_TYPE, + relation_entity_id=entity_id, + ) + ) + await db.flush() + + return CasImportTaskCreateResult( + task_id=task_record.id, + status=task_record.status, + reused=False, + relation_type=CAS_EPISODE_IMPORT_RELATION_TYPE, + relation_entity_id=entity_id, + ) + + +async def run_cas_import_task(task_id: str, run_args: dict | None = None) -> None: + """执行导入任务:成功落 succeeded + result,失败落 failed + error。 + + 签名与 Jellyfish 既有 worker runner 一致 ``(task_id, run_args)``,可直接注册到 + ``AbstractAsyncDelegatingExecutor``;``run_args`` 省略时回落到从任务负载读取。 + + 失败路径:先回滚导入事务,再用**新会话**写任务状态,确保部分写入不会被「写状态」 + 这一步顺带提交;同时补偿删除本次新建的字幕对象,避免孤儿文件。 + """ + async with async_session_maker() as db: + store = SqlAlchemyTaskStore(db) + task = await store.get(task_id) + if task is None: + logger.warning("cas import task not found: %s", task_id) + return + await store.set_status(task_id, TaskStatus.running) + await store.set_progress(task_id, 5) + await db.commit() + if not run_args: + run_args = task.payload.get("run_args") or {} + + result = None + try: + async with async_session_maker() as db: + store = SqlAlchemyTaskStore(db) + try: + package = parse_episode_package(run_args["episode_package"]) + result = await import_episode( + db, + project_id=run_args["project_id"], + package=package, + idempotency_key=run_args["idempotency_key"], + dry_run=bool(run_args.get("dry_run", False)), + ) + await db.commit() + except Exception: + await db.rollback() + raise + # 提交成功后再写任务结果:结果里包含字幕产物信息(file_id / storage_key)。 + await store.set_progress(task_id, 100) + await store.set_result(task_id, result.model_dump(mode="json")) + await store.set_status(task_id, TaskStatus.succeeded) + await db.commit() + except Exception as exc: # noqa: BLE001 # 任何失败都必须落到 failed 状态 + logger.exception("cas import task failed: %s", task_id) + await _compensate_uploaded_artifacts(result) + async with async_session_maker() as db: + store = SqlAlchemyTaskStore(db) + await store.set_error(task_id, str(exc)) + await store.set_status(task_id, TaskStatus.failed) + await db.commit() + + +async def _compensate_uploaded_artifacts(result) -> None: + """导入已上传但事务未能提交时,删除**本次新建**的字幕对象(best-effort)。 + + 只删除 ``created=True`` 的产物:复用既有产物时对象属于上一次成功的导入,不能删。 + """ + if result is None: + return + for artifact in getattr(result, "subtitle_artifacts", []) or []: + if not artifact.created: + continue + try: + await storage.delete_file(key=artifact.storage_key) + except Exception: # noqa: BLE001 # 补偿失败只记录,不掩盖原始错误 + logger.warning("failed to clean up subtitle object: %s", artifact.storage_key) + + +__all__ = [ + "CAS_IMPORT_EPISODE_TASK_KIND", + "CAS_EPISODE_IMPORT_RELATION_TYPE", + "CasImportTaskCreateResult", + "create_cas_import_task", + "find_active_import_task", + "episode_relation_entity_id", + "run_cas_import_task", +] diff --git a/backend/app/crypto_animal_studio/application/parsing.py b/backend/app/crypto_animal_studio/application/parsing.py new file mode 100644 index 00000000..aebf84c1 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/parsing.py @@ -0,0 +1,79 @@ +"""EpisodePackage 版本分派解析(application 层)。 + +显式分派,绝不「顺带」接受两个版本: +- ``"1.0"`` → ``EpisodePackage``(v1 语义完全不变); +- ``"1.1"`` → ``EpisodePackageV11``(附加式扩展); +- 缺失 ``schema_version`` → 沿用既有 v1 行为(必填字段缺失错误),不发明任何回落; +- 未知版本 → 显式 ``UnsupportedSchemaVersionError``,绝不强制升级为最新版本。 + +解析**不会**改写 ``schema_version``、不会把可选对象写回源文档、不会重算既有 payload hash。 +""" + +from __future__ import annotations + +from typing import Any, Union + +from pydantic import ValidationError + +from app.crypto_animal_studio.domain.episode_package import ( + SCHEMA_VERSION, + SCHEMA_VERSION_V1_1, + SUPPORTED_SCHEMA_VERSIONS, +) +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage, EpisodePackageV11 + +AnyEpisodePackage = Union[EpisodePackage, EpisodePackageV11] + + +class UnsupportedSchemaVersionError(ValueError): + """schema_version 不在显式支持集合内。""" + + def __init__(self, version: object) -> None: + """记录被拒绝的版本值与当前支持集合。""" + self.version = version + supported = ", ".join(sorted(SUPPORTED_SCHEMA_VERSIONS)) + super().__init__(f'unsupported schema_version {version!r}; supported versions: {supported}') + + +def parse_episode_package(data: dict[str, Any]) -> AnyEpisodePackage: + """按 ``schema_version`` 显式分派并校验 EpisodePackage。 + + 参数: + data: 原始 dict(不会被修改)。 + 返回: + v1 或 v1.1 的已校验模型实例。 + 异常: + UnsupportedSchemaVersionError: 版本存在但不受支持。 + pydantic.ValidationError: 结构/字段级校验失败(含缺失 schema_version)。 + """ + if not isinstance(data, dict): + raise TypeError("episode package payload must be a mapping") + + version = data.get("schema_version") + if version is None: + # 缺失版本:交给 v1 模型产生既有的「必填字段缺失」错误,不发明回落。 + return EpisodePackage.model_validate(data) + + if version == SCHEMA_VERSION: + return EpisodePackage.model_validate(data) + if version == SCHEMA_VERSION_V1_1: + return EpisodePackageV11.model_validate(data) + raise UnsupportedSchemaVersionError(version) + + +def is_v11(package: AnyEpisodePackage) -> bool: + """判断是否为 v1.1 包(供校验器选择附加规则)。""" + return isinstance(package, EpisodePackageV11) + + +def _reraise_validation(error: ValidationError) -> None: # pragma: no cover - 便于将来包装 + """预留:如需把 pydantic 错误转换为领域错误时使用。""" + raise error + + +__all__ = [ + "parse_episode_package", + "UnsupportedSchemaVersionError", + "AnyEpisodePackage", + "is_v11", +] diff --git a/backend/app/crypto_animal_studio/application/subtitle_artifact.py b/backend/app/crypto_animal_studio/application/subtitle_artifact.py new file mode 100644 index 00000000..90e2a693 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/subtitle_artifact.py @@ -0,0 +1,253 @@ +"""字幕产物:把 v1.1 的 ``localization.subtitle_tracks[]`` 落成 WebVTT 文件产物。 + +**一致性说明(不声称原子性)。** +对象存储(``app.core.storage``,S3/RustFS)**不参与数据库事务**。因此本模块采用 +「确定性键 + 补偿清理」而不是分布式事务: + +1. **确定性存储键**:``cas/subtitles/{project_id}/{episode_id}/{language_tag}.vtt``。 + 同一剧集重复导入会写到同一个 key,内容也逐字节相同 → 覆盖而非新增,不会产生重复对象。 +2. **先写对象、后写数据库行**:这样数据库里绝不会出现指向不存在对象的记录 + (宁可短暂存在「对象在、行未提交」的状态)。 +3. **补偿清理**:若上传成功之后导入失败,``rollback_uploads()`` 会删除**本次新建**的对象 + (不删除本次之前就已存在的对象,避免破坏上一次成功导入的产物)。 +4. **残留边界**:进程被强杀导致补偿未执行时,可能残留一个确定性 key 的孤儿对象; + 它会被下一次成功导入原地覆盖,且因为没有数据库行而不会被前端引用。 + +数据库侧的幂等由 ``file_usages`` 的唯一约束 +``UNIQUE(file_id, usage_kind, source_ref)`` 与稳定的 ``source_ref`` 共同保证。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import storage +from app.crypto_animal_studio.domain.webvtt import WEBVTT_MIME_TYPE, render_webvtt_bytes +from app.models.studio import FileItem, FileType, FileUsage, FileUsageKind +from app.services.common import create_and_refresh + + +class SubtitleArtifactError(Exception): + """字幕产物生成或关联失败。""" + + +@dataclass(slots=True) +class SubtitleArtifactRecord: + """一条已生成/复用的字幕产物。""" + + file_id: str + language_tag: str + storage_key: str + cue_count: int + byte_size: int + created: bool + + +@dataclass(slots=True) +class SubtitleArtifactOutcome: + """本次导入的字幕产物结果与补偿信息。""" + + records: list[SubtitleArtifactRecord] = field(default_factory=list) + #: 本次**新建**的对象键(补偿清理只删这些)。 + uploaded_keys: list[str] = field(default_factory=list) + + async def rollback_uploads(self) -> list[str]: + """删除本次新建的对象;返回删除失败的键(best-effort,不抛出)。""" + failed: list[str] = [] + for key in self.uploaded_keys: + try: + await storage.delete_file(key=key) + except Exception: # noqa: BLE001 # 补偿清理不得掩盖原始异常 + failed.append(key) + return failed + + +def subtitle_storage_key(project_id: str, episode_id: str, language_tag: str) -> str: + """确定性对象键。相同 (project, episode, language) 永远得到同一个 key。""" + return f"cas/subtitles/{project_id}/{episode_id}/{language_tag}.vtt" + + +def subtitle_source_ref(episode_id: str, language_tag: str) -> str: + """``file_usages.source_ref`` 幂等键(配合唯一约束实现同槽位 upsert)。""" + return f"cas:{episode_id}:{language_tag}" + + +def _artifact_name(episode_id: str, language_tag: str) -> str: + """产物展示名。""" + return f"{episode_id}.{language_tag}.vtt" + + +async def _object_exists(key: str) -> bool: + """对象是否已存在(用于判断本次是否为新建,从而决定补偿是否删除它)。""" + try: + await storage.get_file_info(key=key) + return True + except Exception: # noqa: BLE001 # 不存在或后端不支持 head → 视为不存在 + return False + + +async def ensure_subtitle_artifacts( + db: AsyncSession, + *, + package: Any, + project_id: str, + chapter_id: str, +) -> SubtitleArtifactOutcome: + """为包内每条字幕轨生成/复用一个 WebVTT 产物,并关联到 Project + Chapter。 + + v1 文档没有 ``localization``,直接返回空结果,因此既有 v1 行为完全不变。 + + 参数: + db: 当前导入事务的会话(只 flush,不 commit)。 + package: 已通过 QA 闸门的 EpisodePackage(v1 或 v1.1)。 + project_id: 目标项目。 + chapter_id: 本次导入产生的章节。 + 返回: + SubtitleArtifactOutcome(含产物列表与补偿用的新建对象键)。 + 异常: + SubtitleArtifactError:渲染或上传失败(调用方须回滚事务并执行补偿清理)。 + """ + outcome = SubtitleArtifactOutcome() + localization = getattr(package, "localization", None) + if localization is None: + return outcome + + episode_id = package.episode_id + for track in localization.subtitle_tracks: + key = subtitle_storage_key(project_id, episode_id, track.language_tag) + source_ref = subtitle_source_ref(episode_id, track.language_tag) + + try: + payload = render_webvtt_bytes(track) + except ValueError as exc: + raise SubtitleArtifactError( + f"subtitle track '{track.language_tag}': {exc}" + ) from exc + + existed_before = await _object_exists(key) + try: + await storage.upload_file(key=key, data=payload, content_type=WEBVTT_MIME_TYPE) + except Exception as exc: # noqa: BLE001 # 统一转为领域异常,交由调用方回滚 + raise SubtitleArtifactError( + f"failed to upload subtitle artifact '{key}': {exc}" + ) from exc + if not existed_before: + outcome.uploaded_keys.append(key) + + # 数据库侧:按 (usage_kind, source_ref) 复用既有 FileItem,避免重复行。 + existing_usage = ( + await db.execute( + select(FileUsage).where( + FileUsage.usage_kind == FileUsageKind.subtitle, + FileUsage.source_ref == source_ref, + FileUsage.project_id == project_id, + ) + ) + ).scalars().first() + + if existing_usage is not None: + file_item = await db.get(FileItem, existing_usage.file_id) + if file_item is not None: + # 确定性更新:key 与展示名保持一致,章节指向最新一次导入。 + file_item.storage_key = key + file_item.name = _artifact_name(episode_id, track.language_tag) + existing_usage.chapter_id = chapter_id + await db.flush() + outcome.records.append( + SubtitleArtifactRecord( + file_id=file_item.id, + language_tag=track.language_tag, + storage_key=key, + cue_count=len(track.cues), + byte_size=len(payload), + created=False, + ) + ) + continue + + file_item = FileItem( + id=str(uuid.uuid4()), + type=FileType.subtitle, + name=_artifact_name(episode_id, track.language_tag), + thumbnail="", + tags=["cas", "subtitle", track.language_tag, episode_id], + storage_key=key, + ) + await create_and_refresh(db, file_item) + await create_and_refresh( + db, + FileUsage( + file_id=file_item.id, + project_id=project_id, + chapter_id=chapter_id, + shot_id=None, # 轨是剧集级的;单条 cue 的镜头引用保留在 WebVTT NOTE 中 + usage_kind=FileUsageKind.subtitle, + source_ref=source_ref, + ), + ) + outcome.records.append( + SubtitleArtifactRecord( + file_id=file_item.id, + language_tag=track.language_tag, + storage_key=key, + cue_count=len(track.cues), + byte_size=len(payload), + created=True, + ) + ) + + return outcome + + +async def lookup_subtitle_artifacts( + db: AsyncSession, *, package: Any, project_id: str +) -> list[SubtitleArtifactRecord]: + """查询该剧集已存在的字幕产物(用于幂等重放时如实报告,不做任何写入)。""" + records: list[SubtitleArtifactRecord] = [] + localization = getattr(package, "localization", None) + if localization is None: + return records + + for track in localization.subtitle_tracks: + source_ref = subtitle_source_ref(package.episode_id, track.language_tag) + usage = ( + await db.execute( + select(FileUsage).where( + FileUsage.usage_kind == FileUsageKind.subtitle, + FileUsage.source_ref == source_ref, + FileUsage.project_id == project_id, + ) + ) + ).scalars().first() + if usage is None: + continue + file_item = await db.get(FileItem, usage.file_id) + if file_item is None: + continue + records.append( + SubtitleArtifactRecord( + file_id=file_item.id, + language_tag=track.language_tag, + storage_key=file_item.storage_key, + cue_count=len(track.cues), + byte_size=len(render_webvtt_bytes(track)), + created=False, + ) + ) + return records + + +__all__ = [ + "SubtitleArtifactError", + "lookup_subtitle_artifacts", + "SubtitleArtifactOutcome", + "SubtitleArtifactRecord", + "ensure_subtitle_artifacts", + "subtitle_source_ref", + "subtitle_storage_key", +] diff --git a/backend/app/crypto_animal_studio/application/validation.py b/backend/app/crypto_animal_studio/application/validation.py new file mode 100644 index 00000000..a2313130 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/validation.py @@ -0,0 +1,575 @@ +"""EpisodePackage 生命周期校验(application 层)。 + +阶段(累积执行,后一阶段包含前面全部规则): +``design`` → ``pre_render_data_lock`` → ``provider_input`` → ``post_production`` → ``publish`` + +规范:docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md §9;决策:ADR-016 §7。 + +要点: +- 设计阶段允许 ``{{占位符}}``;data-lock 与 publish 阶段一律拒绝。 +- 时长真相唯一来自 ``domain.runtime.derive_runtime``(不在本模块重复公式)。 +- 传统 v1 包不会因为「没有 localization / 字幕 / fact card / market_data / references / + post_production」而失败。 +- 错误只报告字段路径与类别,**不回显疑似机密内容**。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from app.crypto_animal_studio.domain import market_facts, provider_safety +from app.crypto_animal_studio.domain.runtime import ( + RUNTIME_ASSERTION_TOLERANCE_MS, + DerivedRuntime, + derive_runtime, + seconds_to_ms, +) +from app.crypto_animal_studio.schemas.episode_package import EpisodePackageV11 + +#: 镜头关联叠加/字幕的时间容差(毫秒)。 +SHOT_ASSOCIATION_TOLERANCE_MS: int = 150 + +#: 发布时长范围(Bible / ADR-015 的 15–30 秒规则)。 +PUBLISH_MIN_TOTAL_MS: int = 15_000 +PUBLISH_MAX_TOTAL_MS: int = 30_000 + +#: 禁用措辞。刻意只拦「承诺式」表达,并豁免否定式: +#: - ``guaranteed``:前面紧跟 ``not `` 时豁免("not guaranteed" 合法); +#: 裸动词 ``guarantee``(如 "don't guarantee future performance")不视为违规; +#: - ``financial advice``:前面紧跟 ``not `` 时豁免(免责声明用语合法)。 +_PROHIBITED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("guaranteed", re.compile(r"(? list[ValidationIssue]: + """全部错误。""" + return [item for item in self.issues if item.severity == "error"] + + @property + def warnings(self) -> list[ValidationIssue]: + """全部告警。""" + return [item for item in self.issues if item.severity == "warning"] + + @property + def ok(self) -> bool: + """无错误即通过(告警不阻断)。""" + return not self.errors + + def codes(self) -> set[str]: + """便于测试断言的错误码集合。""" + return {item.code for item in self.issues} + + +class _Collector: + """内部收集器。""" + + def __init__(self) -> None: + self.issues: list[ValidationIssue] = [] + + def error(self, code: str, field_path: str, message: str) -> None: + """记录一条错误。""" + self.issues.append(ValidationIssue("error", code, field_path, message)) + + def warn(self, code: str, field_path: str, message: str) -> None: + """记录一条告警。""" + self.issues.append(ValidationIssue("warning", code, field_path, message)) + + +# --------------------------------------------------------------------------- # +# 公开入口 +# --------------------------------------------------------------------------- # +def validate_episode_package(package: Any, *, stage: ValidationStage) -> ValidationResult: + """按阶段校验一个**已解析**的 EpisodePackage(v1 或 v1.1)。 + + 参数: + package: ``EpisodePackage`` 或 ``EpisodePackageV11`` 实例。 + stage: 目标阶段;会累积执行该阶段及其之前的全部规则。 + 返回: + ``ValidationResult``(区分 error / warning,并给出字段路径)。 + """ + collector = _Collector() + runtime = derive_runtime(package.shots, getattr(package, "fact_card", None)) + is_v11 = isinstance(package, EpisodePackageV11) + target_index = STAGE_ORDER.index(stage) + + _validate_design(package, is_v11, runtime, collector) + if target_index >= STAGE_ORDER.index(ValidationStage.pre_render_data_lock): + _validate_pre_render(package, is_v11, runtime, collector) + if target_index >= STAGE_ORDER.index(ValidationStage.provider_input): + _validate_provider_input(package, is_v11, collector) + if target_index >= STAGE_ORDER.index(ValidationStage.post_production): + _validate_post_production(package, is_v11, runtime, collector) + if target_index >= STAGE_ORDER.index(ValidationStage.publish): + _validate_publish(package, is_v11, runtime, collector) + + return ValidationResult(stage=stage, issues=collector.issues) + + +def derived_runtime_for(package: Any) -> DerivedRuntime: + """暴露权威派生时长(供 API/CLI/测试复用,避免重复公式)。""" + return derive_runtime(package.shots, getattr(package, "fact_card", None)) + + +# --------------------------------------------------------------------------- # +# 阶段实现 +# --------------------------------------------------------------------------- # +def _shot_windows(package: Any) -> dict[str, tuple[int, int]]: + """按 sequence 顺序计算每个镜头的 episode-absolute 毫秒窗口。""" + windows: dict[str, tuple[int, int]] = {} + cursor = 0 + for shot in sorted(package.shots, key=lambda item: item.sequence): + span = seconds_to_ms(shot.duration_seconds) + windows[shot.shot_id] = (cursor, cursor + span) + cursor += span + return windows + + +def _validate_design(package: Any, is_v11: bool, runtime: DerivedRuntime, out: _Collector) -> None: + """结构、ID、引用、语言标签形状、时间结构与结构性供应商中立检查。""" + # 供应商中立性:结构上可检测的部分(v1 与 v1.1 都查)。 + for finding in provider_safety.scan(package.model_dump(mode="json")): + out.error( + "provider_neutrality_violation", + finding.field_path, + f"forbidden content category '{finding.category}' detected (value not echoed)", + ) + + if not is_v11: + return # v1 包没有新增对象,设计阶段无附加规则 + + character_keys = {item.character_key for item in package.characters} + scene_keys = {item.scene_key for item in package.assets.scenes} + prop_keys = {item.prop_key for item in package.assets.props} + shot_ids = {shot.shot_id for shot in package.shots} + + localization = package.localization + if localization is not None: + if localization.spoken_language is not None and not market_facts.is_valid_language_tag( + localization.spoken_language + ): + out.error("invalid_language_tag", "localization.spoken_language", "not a valid BCP 47 shape") + for index, tag in enumerate(localization.required_publish_language_tags): + if not market_facts.is_valid_language_tag(tag): + out.error( + "invalid_language_tag", + f"localization.required_publish_language_tags[{index}]", + "not a valid BCP 47 shape", + ) + seen_tracks: set[str] = set() + for t_index, track in enumerate(localization.subtitle_tracks): + path = f"localization.subtitle_tracks[{t_index}]" + if not market_facts.is_valid_language_tag(track.language_tag): + out.error("invalid_language_tag", f"{path}.language_tag", "not a valid BCP 47 shape") + if track.language_tag in seen_tracks: + out.error("duplicate_subtitle_track", f"{path}.language_tag", "duplicate subtitle track language") + seen_tracks.add(track.language_tag) + seen_cues: set[str] = set() + for c_index, cue in enumerate(track.cues): + cue_path = f"{path}.cues[{c_index}]" + if cue.cue_id in seen_cues: + out.error("duplicate_cue_id", f"{cue_path}.cue_id", "duplicate cue_id within track") + seen_cues.add(cue.cue_id) + if cue.speaker_character_key is not None and cue.speaker_character_key not in character_keys: + out.error( + "unknown_character_reference", + f"{cue_path}.speaker_character_key", + f"unknown character_key '{cue.speaker_character_key}'", + ) + if cue.shot_id is not None and cue.shot_id not in shot_ids: + out.error("unknown_shot_reference", f"{cue_path}.shot_id", f"unknown shot_id '{cue.shot_id}'") + + post = package.post_production + overlay_ids: set[str] = set() + if post is not None: + for o_index, overlay in enumerate(post.overlays): + path = f"post_production.overlays[{o_index}]" + if overlay.overlay_id in overlay_ids: + out.error("duplicate_overlay_id", f"{path}.overlay_id", "duplicate overlay_id") + overlay_ids.add(overlay.overlay_id) + if overlay.shot_id is not None and overlay.shot_id not in shot_ids: + out.error("unknown_shot_reference", f"{path}.shot_id", f"unknown shot_id '{overlay.shot_id}'") + for l_index, copy in enumerate(overlay.localized): + if not market_facts.is_valid_language_tag(copy.language_tag): + out.error( + "invalid_language_tag", + f"{path}.localized[{l_index}].language_tag", + "not a valid BCP 47 shape", + ) + + for s_index, shot in enumerate(package.shots): + for r_index, overlay_id in enumerate(shot.overlay_ids): + if overlay_id not in overlay_ids: + out.error( + "unknown_overlay_reference", + f"shots[{s_index}].overlay_ids[{r_index}]", + f"unknown overlay_id '{overlay_id}'", + ) + + fact_card = package.fact_card + if fact_card is not None: + for f_index, copy in enumerate(fact_card.localized): + if not market_facts.is_valid_language_tag(copy.language_tag): + out.error( + "invalid_language_tag", + f"fact_card.localized[{f_index}].language_tag", + "not a valid BCP 47 shape", + ) + if any(shot.shot_id.strip().lower() in {"fact_card", "factcard"} for shot in package.shots): + out.error("fact_card_as_shot", "shots[]", "fact card must not be represented as a generated shot") + + refs = package.references + if refs is not None: + for group, keys, attr in ( + ("characters", character_keys, "character_key"), + ("environments", scene_keys, "scene_key"), + ("props", prop_keys, "prop_key"), + ): + for index, asset in enumerate(getattr(refs, group)): + path = f"references.{group}[{index}]" + key_value = getattr(asset, attr) + if key_value is None: + out.error("missing_reference_key", f"{path}.{attr}", f"{attr} is required for {group} references") + elif key_value not in keys: + out.error("unknown_reference_key", f"{path}.{attr}", f"unknown {attr} '{key_value}'") + if asset.path is not None: + if re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", asset.path.strip()): + out.error("invalid_asset_path", f"{path}.path", "asset path must be repository-relative") + elif asset.path.startswith("/") or ".." in asset.path: + out.error("invalid_asset_path", f"{path}.path", "asset path must not be absolute or traverse") + + if package.output is not None and not re.match(r"^\d+:\d+$", package.output.aspect_ratio): + out.error("invalid_aspect_ratio", "output.aspect_ratio", "aspect_ratio must look like W:H") + + _ = runtime # 设计阶段不做时长断言 + + +def _validate_pre_render(package: Any, is_v11: bool, runtime: DerivedRuntime, out: _Collector) -> None: + """事实解析、占位符清零、时长断言、输出格式与时间有效性。""" + if not is_v11: + return + + dumped = package.model_dump(mode="json") + + # 占位符:规范覆盖的字段子树 + for subtree in ("market_data", "fact_card", "post_production"): + node = dumped.get(subtree) + if node is None: + continue + for path in market_facts.iter_placeholder_paths(node, subtree): + out.error("unresolved_placeholder", path, "unresolved {{placeholder}} is not allowed at data lock") + + market = package.market_data + if market is not None: + if market.data_lock.status != "locked": + out.error( + "data_lock_required", + "market_data.data_lock.status", + "market-data-dependent rendering requires data_lock.status == 'locked'", + ) + for name in ("as_of_utc", "source_name", "factual_note"): + value = getattr(market, name) + if value is None or market_facts.is_blank(value): + out.error("missing_required_market_fact", f"market_data.{name}", "required and must be non-empty") + for name in ("price", "resistance_level"): + value = getattr(market, name) + if value is not None and market_facts.parse_decimal(value) is None: + out.error("unparseable_decimal", f"market_data.{name}", "not a finite decimal value") + for name in ("price_move_pct", "pullback_pct"): + value = getattr(market, name) + if value is not None and market_facts.parse_percentage(value) is None: + out.error("unparseable_percentage", f"market_data.{name}", "not a finite percentage value") + for name in ("event_timestamp_utc", "candle_close_timestamp_utc", "as_of_utc"): + value = getattr(market, name) + if value is not None and market_facts.parse_iso8601(value) is None: + out.error("unparseable_timestamp", f"market_data.{name}", "not an ISO-8601 instant") + + # 时长断言(派生值权威;恰好 50 ms 允许) + output = package.output + if output is not None: + if output.generated_footage_ms is not None: + delta = abs(output.generated_footage_ms - runtime.generated_ms) + if delta > RUNTIME_ASSERTION_TOLERANCE_MS: + out.error( + "runtime_assertion_mismatch", + "output.generated_footage_ms", + f"assertion differs from derived value by {delta} ms (> {RUNTIME_ASSERTION_TOLERANCE_MS} ms)", + ) + if output.total_runtime_ms is not None: + delta = abs(output.total_runtime_ms - runtime.total_ms) + if delta > RUNTIME_ASSERTION_TOLERANCE_MS: + out.error( + "runtime_assertion_mismatch", + "output.total_runtime_ms", + f"assertion differs from derived value by {delta} ms (> {RUNTIME_ASSERTION_TOLERANCE_MS} ms)", + ) + + # target_duration_seconds 只产生告警 + target_ms = package.creative_direction.target_duration_seconds * 1000 + if target_ms != runtime.total_ms: + out.warn( + "target_duration_mismatch", + "creative_direction.target_duration_seconds", + f"author intent {target_ms} ms differs from derived {runtime.total_ms} ms (non-authoritative)", + ) + + # 每个镜头必须恰好一个运镜(canonical 路径) + for index, shot in enumerate(package.shots): + if shot.camera is None or shot.camera.movement is None: + out.error( + "missing_camera_movement", + f"shots[{index}].camera.movement", + "canonical episodes require exactly one dominant camera movement", + ) + + _validate_timing(package, runtime, out) + + +def _validate_timing(package: Any, runtime: DerivedRuntime, out: _Collector) -> None: + """字幕 cue 与叠加的 episode-absolute 时间有效性 + 镜头关联交叉检查。""" + windows = _shot_windows(package) + + localization = package.localization + if localization is not None: + for t_index, track in enumerate(localization.subtitle_tracks): + previous_end: int | None = None + for c_index, cue in enumerate(sorted(track.cues, key=lambda item: item.start_ms)): + path = f"localization.subtitle_tracks[{t_index}].cues[{c_index}]" + if cue.end_ms > runtime.total_ms: + out.error("cue_out_of_runtime", path, f"end_ms exceeds derived_total_ms ({runtime.total_ms})") + if previous_end is not None and cue.start_ms < previous_end: + out.error("cue_overlap", path, "cues within a track must not overlap") + previous_end = cue.end_ms + if cue.shot_id is not None and cue.shot_id in windows: + start, end = windows[cue.shot_id] + if ( + cue.start_ms < start - SHOT_ASSOCIATION_TOLERANCE_MS + or cue.end_ms > end + SHOT_ASSOCIATION_TOLERANCE_MS + ): + out.error( + "shot_association_mismatch", + path, + f"cue falls outside shot window [{start},{end}] ±{SHOT_ASSOCIATION_TOLERANCE_MS} ms", + ) + + post = package.post_production + if post is not None: + for o_index, overlay in enumerate(post.overlays): + path = f"post_production.overlays[{o_index}]" + if overlay.start_ms is None or overlay.end_ms is None: + continue + if overlay.end_ms > runtime.total_ms: + out.error("overlay_out_of_runtime", path, f"end_ms exceeds derived_total_ms ({runtime.total_ms})") + if overlay.shot_id is not None and overlay.shot_id in windows: + start, end = windows[overlay.shot_id] + if ( + overlay.start_ms < start - SHOT_ASSOCIATION_TOLERANCE_MS + or overlay.end_ms > end + SHOT_ASSOCIATION_TOLERANCE_MS + ): + out.error( + "shot_association_mismatch", + path, + f"overlay falls outside shot window [{start},{end}] ±{SHOT_ASSOCIATION_TOLERANCE_MS} ms", + ) + + +def _validate_provider_input(package: Any, is_v11: bool, out: _Collector) -> None: + """生成输入与参考资产可解析;再次确认无禁止 URL/凭证(不发起任何网络请求)。""" + for finding in provider_safety.scan(package.model_dump(mode="json")): + out.error( + "provider_neutrality_violation", + finding.field_path, + f"forbidden content category '{finding.category}' detected (value not echoed)", + ) + if not is_v11: + return + + refs = package.references + if refs is not None: + # 规范(§9.3 Provider-input):"resolvable references for every character **it uses**"。 + # 因此只要求**被镜头实际使用**的角色可解析;仅声明而未出场的角色不作要求。 + referenced = {item.character_key for item in refs.characters if item.character_key} + for s_index, shot in enumerate(package.shots): + for c_index, key in enumerate(shot.character_keys): + if key not in referenced: + out.error( + "missing_character_reference", + f"shots[{s_index}].character_keys[{c_index}]", + f"no reference asset declared for character '{key}' used by this shot", + ) + for index, shot in enumerate(package.shots): + if not shot.beginning_state.strip() or not shot.ending_state.strip(): + out.warn( + "missing_shot_state", + f"shots[{index}]", + "beginning_state/ending_state recommended for generation input", + ) + + +def _validate_post_production(package: Any, is_v11: bool, runtime: DerivedRuntime, out: _Collector) -> None: + """必需字幕语言、非空轨、可放置的必需叠加、fact card 起点与免责声明。""" + if not is_v11: + return + + localization = package.localization + required_tags: list[str] = list(localization.required_publish_language_tags) if localization else [] + tracks = {track.language_tag: track for track in (localization.subtitle_tracks if localization else [])} + + # 声明即承诺:任何已声明的轨都不能为空 + if localization is not None: + for t_index, track in enumerate(localization.subtitle_tracks): + if not track.cues or all(not cue.text.strip() for cue in track.cues): + out.error( + "empty_subtitle_track", + f"localization.subtitle_tracks[{t_index}].cues", + "declared subtitle track must contain at least one non-empty cue", + ) + + for index, tag in enumerate(required_tags): + track = tracks.get(tag) # 精确匹配,不做 locale 回落 + if track is None: + out.error( + "missing_required_subtitle_track", + f"localization.required_publish_language_tags[{index}]", + f"no subtitle track for required publish language '{tag}'", + ) + elif not track.cues or all(not cue.text.strip() for cue in track.cues): + out.error( + "empty_required_subtitle_track", + f"localization.subtitle_tracks[{tag}].cues", + f"required publish language '{tag}' has no non-empty cue", + ) + + post = package.post_production + if post is not None: + for o_index, overlay in enumerate(post.overlays): + path = f"post_production.overlays[{o_index}]" + if overlay.required and (overlay.start_ms is None or overlay.end_ms is None): + out.error("unplaceable_overlay", path, "required overlay must declare start_ms and end_ms") + if overlay.type == "fact_card" and overlay.start_ms is not None: + if package.fact_card is not None and package.fact_card.placement == "append_after_shots": + if overlay.start_ms != runtime.generated_ms: + out.error( + "fact_card_interval_mismatch", + f"{path}.start_ms", + f"appended fact card must begin at derived_generated_ms ({runtime.generated_ms})", + ) + + fact_card = package.fact_card + if fact_card is not None: + for tag in required_tags: + if not any(copy.language_tag == tag for copy in fact_card.localized): + out.error( + "missing_fact_card_language", + "fact_card.localized", + f"no fact card copy for required publish language '{tag}'", + ) + + +def _validate_publish(package: Any, is_v11: bool, runtime: DerivedRuntime, out: _Collector) -> None: + """发布闸门:占位符复检、禁用措辞、免责声明与 15–30 秒时长。""" + if not (PUBLISH_MIN_TOTAL_MS <= runtime.total_ms <= PUBLISH_MAX_TOTAL_MS): + out.error( + "runtime_out_of_publish_range", + "shots[].duration_seconds", + f"derived_total_ms {runtime.total_ms} outside {PUBLISH_MIN_TOTAL_MS}-{PUBLISH_MAX_TOTAL_MS} ms", + ) + + dumped = package.model_dump(mode="json") + + # 占位符全文复检(data-lock 不被视为永久保证) + for path in market_facts.iter_placeholder_paths(dumped): + out.error("unresolved_placeholder", path, "unresolved {{placeholder}} is not allowed at publish") + + # 禁用措辞:对白 + 事实备注 + 卡面正文 + 叠加文案(免责声明字段本身豁免) + texts: list[tuple[str, str]] = [] + for s_index, shot in enumerate(package.shots): + for d_index, line in enumerate(shot.dialogue): + texts.append((f"shots[{s_index}].dialogue[{d_index}].text", line.text)) + if is_v11: + market = package.market_data + if market is not None and market.factual_note: + texts.append(("market_data.factual_note", market.factual_note)) + if package.fact_card is not None: + for f_index, copy in enumerate(package.fact_card.localized): + for b_index, body in enumerate(copy.body): + texts.append((f"fact_card.localized[{f_index}].body[{b_index}]", body)) + if package.post_production is not None: + for o_index, overlay in enumerate(package.post_production.overlays): + for l_index, copy in enumerate(overlay.localized): + texts.append((f"post_production.overlays[{o_index}].localized[{l_index}].text", copy.text)) + + for path, text in texts: + for code, pattern in _PROHIBITED_PATTERNS: + if pattern.search(text): + out.error("prohibited_phrase", path, f"prohibited phrasing category '{code}'") + + if is_v11: + fact_card = package.fact_card + if fact_card is not None: + for f_index, copy in enumerate(fact_card.localized): + if not copy.disclaimer.strip(): + out.error( + "missing_disclaimer", + f"fact_card.localized[{f_index}].disclaimer", + "disclaimer is required for every published language", + ) + + +__all__ = [ + "ValidationStage", + "ValidationIssue", + "ValidationResult", + "validate_episode_package", + "derived_runtime_for", + "STAGE_ORDER", + "SHOT_ASSOCIATION_TOLERANCE_MS", + "PUBLISH_MIN_TOTAL_MS", + "PUBLISH_MAX_TOTAL_MS", +] diff --git a/backend/app/crypto_animal_studio/domain/episode_package.py b/backend/app/crypto_animal_studio/domain/episode_package.py index 90f1c3d8..0544a9fa 100644 --- a/backend/app/crypto_animal_studio/domain/episode_package.py +++ b/backend/app/crypto_animal_studio/domain/episode_package.py @@ -12,8 +12,13 @@ from enum import Enum from typing import Literal -# EpisodePackage 契约版本。Sprint 2 固定为 "1.0";升级规则见 docs/episode-package-v1.md。 +# EpisodePackage 契约版本。v1 = "1.0";v1.1 = "1.1"(附加式扩展,见 +# docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md 与 docs/adr/ADR-016)。 SCHEMA_VERSION: str = "1.0" +SCHEMA_VERSION_V1_1: str = "1.1" + +#: 解析器显式支持的版本集合(成员判断,绝不用区间/前缀比较)。 +SUPPORTED_SCHEMA_VERSIONS: frozenset[str] = frozenset({SCHEMA_VERSION, SCHEMA_VERSION_V1_1}) # 新闻/素材来源类型的合法取值。schemas 层以 Literal 复用该集合的语义。 SourceType = Literal["news", "original", "fictional", "generic"] diff --git a/backend/app/crypto_animal_studio/domain/market_facts.py b/backend/app/crypto_animal_studio/domain/market_facts.py new file mode 100644 index 00000000..848dcc33 --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/market_facts.py @@ -0,0 +1,124 @@ +"""市场事实字符串的解析与占位符检测(domain 层,纯函数、无副作用)。 + +v1.1 刻意保留「可含占位符的字符串」表示法(最小化改动): +- 设计阶段允许 ``{{TOKEN}}``; +- data-lock 阶段拒绝任何未解析占位符,并要求按语义规则可解析。 + +**所有函数都不修改传入值**(只读判断/解析)。 +""" + +from __future__ import annotations + +import re +from datetime import datetime +from decimal import Decimal, InvalidOperation + +#: 占位符语法:仅 ``{{...}}`` 形式;普通散文与单花括号不视为占位符。 +PLACEHOLDER_PATTERN = re.compile(r"\{\{[^{}]*\}\}") + +#: 被禁止的 NaN 类记号(比较前 strip + casefold)。 +NAN_LIKE_TOKENS: frozenset[str] = frozenset( + {"nan", "inf", "+inf", "-inf", "infinity", "+infinity", "-infinity", "none", "null", "undefined", "tbd", "?"} +) + +#: 解析十进制数前允许剥除的货币符号与千分位分隔符。 +_CURRENCY_CHARS = "$€£¥₩" +_THOUSANDS_CHARS = "," + +#: 宽松的 BCP 47 形状校验(形状检查,不做注册表校验)。 +BCP47_PATTERN = re.compile( + r"^[A-Za-z]{2,3}(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*$" +) + + +def contains_placeholder(value: object) -> bool: + """判断字符串是否含未解析的 ``{{...}}`` 占位符(非字符串一律 False)。""" + return isinstance(value, str) and PLACEHOLDER_PATTERN.search(value) is not None + + +def is_blank(value: object) -> bool: + """判断是否为空串或仅空白(非字符串一律 False)。""" + return isinstance(value, str) and value.strip() == "" + + +def is_nan_like(value: object) -> bool: + """判断是否为被禁止的 NaN 类记号(strip + casefold 后比较)。""" + return isinstance(value, str) and value.strip().casefold() in NAN_LIKE_TOKENS + + +def is_valid_language_tag(value: object) -> bool: + """判断字符串是否符合 BCP 47 形状(例如 ``en``、``zh-Hant``)。""" + return isinstance(value, str) and BCP47_PATTERN.match(value) is not None + + +def parse_decimal(value: str) -> Decimal | None: + """把价格/价位字符串解析为有限十进制数;失败返回 ``None``。 + + 允许:首尾空白、货币符号、千分位逗号、前导正负号。 + 不修改传入字符串。 + """ + if not isinstance(value, str) or is_blank(value) or is_nan_like(value) or contains_placeholder(value): + return None + cleaned = value.strip() + for char in _CURRENCY_CHARS: + cleaned = cleaned.replace(char, "") + cleaned = cleaned.replace(_THOUSANDS_CHARS, "").strip() + if cleaned in {"", "+", "-"}: + return None + try: + parsed = Decimal(cleaned) + except (InvalidOperation, ValueError): + return None + return parsed if parsed.is_finite() else None + + +def parse_percentage(value: str) -> Decimal | None: + """把百分比字符串解析为十进制数(允许剥除一个尾部 ``%``);失败返回 ``None``。""" + if not isinstance(value, str) or is_blank(value) or is_nan_like(value) or contains_placeholder(value): + return None + cleaned = value.strip() + if cleaned.endswith("%"): + cleaned = cleaned[:-1].strip() + return parse_decimal(cleaned) + + +def parse_iso8601(value: str) -> datetime | None: + """把 ISO-8601 字符串解析为 ``datetime``;失败返回 ``None``(支持尾部 ``Z``)。""" + if not isinstance(value, str) or is_blank(value) or is_nan_like(value) or contains_placeholder(value): + return None + candidate = value.strip() + if candidate.endswith(("Z", "z")): + candidate = candidate[:-1] + "+00:00" + try: + return datetime.fromisoformat(candidate) + except ValueError: + return None + + +def iter_placeholder_paths(node: object, path: str = "") -> list[str]: + """递归收集所有含占位符的字段路径(用于 data-lock / publish 扫描)。""" + found: list[str] = [] + if isinstance(node, dict): + for key, item in node.items(): + found.extend(iter_placeholder_paths(item, f"{path}.{key}" if path else str(key))) + elif isinstance(node, (list, tuple)): + for index, item in enumerate(node): + found.extend(iter_placeholder_paths(item, f"{path}[{index}]")) + elif contains_placeholder(node): + found.append(path or "") + return found + + +__all__ = [ + "PLACEHOLDER_PATTERN", + "NAN_LIKE_TOKENS", + "BCP47_PATTERN", + "contains_placeholder", + "is_blank", + "is_nan_like", + "is_valid_language_tag", + "parse_decimal", + "parse_percentage", + "parse_iso8601", + "iter_placeholder_paths", +] diff --git a/backend/app/crypto_animal_studio/domain/provider_safety.py b/backend/app/crypto_animal_studio/domain/provider_safety.py new file mode 100644 index 00000000..2b3d4755 --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/provider_safety.py @@ -0,0 +1,180 @@ +"""供应商中立性与 URL 安全检查(domain 层,纯函数)。 + +规范(ADR-016 §8): +- **允许**:公开的市场数据溯源 URL(``market_data.source_url``)、仓库相对资产路径、 + 不透明 asset ID。 +- **禁止**:供应商 API 端点、签名 URL、临时/过期下载 URL、账户专属执行 URL、 + 凭证/API key/authorization header/token、供应商原生生成请求负载。 + +``source_url`` 只是**溯源证据**,不是执行端点:本模块只做结构判断, +**绝不发起任何网络请求**。 + +错误只报告「字段路径 + 类别」,**不回显被判定为机密的内容**。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +#: 已知供应商 API 主机片段(出现即视为执行端点)。 +PROVIDER_API_HOST_MARKERS: tuple[str, ...] = ( + "api.openai.com", + "api.anthropic.com", + "api.elevenlabs.io", + "api.runwayml.com", + "api.replicate.com", + "api.stability.ai", + "generativelanguage.googleapis.com", + "ark.cn-beijing.volces.com", + "dashscope.aliyuncs.com", + "api.groq.com", + "api.deepseek.com", +) + +#: 生成类 API 路径片段。 +PROVIDER_API_PATH_MARKERS: tuple[str, ...] = ( + "/v1/images/generations", + "/v1/chat/completions", + "/v1/audio/speech", + "/v1/videos", + "/v1/completions", + "/v1/embeddings", + "/api/v3/images/generations", +) + +#: 签名/过期 URL 查询参数标记。 +SIGNED_URL_MARKERS: tuple[str, ...] = ( + "x-amz-signature", + "x-amz-credential", + "awsaccesskeyid", + "x-goog-signature", + "signature=", + "sig=", + "expires=", + "x-amz-expires", + "se=", + "st=", + "token=", + "access_token=", +) + +#: 账户/租户专属执行路径标记。 +ACCOUNT_SCOPED_MARKERS: tuple[str, ...] = ("/accounts/", "/workspaces/", "/organizations/", "/tenants/", "/projects/") + +#: 明显的密钥/令牌形状。 +#: 顺序有意义:``authorization`` 头必须先于裸 ``Bearer`` 令牌匹配, +#: 否则 "Authorization: Bearer …" 会被误判为 bearer_token。 +_SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("authorization_header", re.compile(r"\bauthorization\s*:\s*\S+", re.IGNORECASE)), + ("api_key", re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}")), + ("api_key", re.compile(r"\bAKIA[0-9A-Z]{12,}")), + ("api_key", re.compile(r"\bghp_[A-Za-z0-9]{20,}")), + ("bearer_token", re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{10,}", re.IGNORECASE)), +) + +#: 自由字典(如 ``shots[].metadata``)中禁止出现的键名。 +FORBIDDEN_FREEFORM_KEYS: frozenset[str] = frozenset( + { + "api_key", + "apikey", + "api-key", + "authorization", + "auth_header", + "bearer", + "token", + "access_token", + "refresh_token", + "secret", + "client_secret", + "provider_endpoint", + "endpoint_url", + "request_payload", + "provider_request", + "provider_payload", + } +) + +_CREDENTIALS_IN_URL = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://[^/\s:@]+:[^/\s@]+@", re.IGNORECASE) +_HAS_SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://") + + +@dataclass(frozen=True, slots=True) +class SafetyFinding: + """一条供应商中立性问题:只含路径与类别,不含疑似机密内容。""" + + field_path: str + category: str + + +#: URL 类别判定表:(类别, 标记集合)。顺序即优先级。 +_URL_MARKER_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("provider_api_endpoint", PROVIDER_API_HOST_MARKERS + PROVIDER_API_PATH_MARKERS), + ("signed_or_expiring_url", SIGNED_URL_MARKERS), + ("account_scoped_url", ACCOUNT_SCOPED_MARKERS), +) + + +def classify_string(value: str) -> str | None: + """判断单个字符串是否落入禁止类别;返回类别名或 ``None``。 + + 类别:``credentials_in_url``、``provider_api_endpoint``、``signed_or_expiring_url``、 + ``account_scoped_url``、``api_key``、``bearer_token``、``authorization_header``。 + + 允许(返回 ``None``):无 scheme 的仓库相对路径与不透明 asset ID、 + 以及不含上述标记的公开溯源 URL。 + """ + if not isinstance(value, str) or not value.strip(): + return None + + stripped = value.strip() + + secret_category = next((category for category, pattern in _SECRET_PATTERNS if pattern.search(value)), None) + if secret_category is not None: + return secret_category + + if _CREDENTIALS_IN_URL.match(stripped): + return "credentials_in_url" + + if not _HAS_SCHEME.match(stripped): + return None # 仓库相对路径 / 不透明 asset ID:允许 + + lowered = value.lower() + return next( + (category for category, markers in _URL_MARKER_RULES if any(marker in lowered for marker in markers)), + None, + ) + + +def scan(node: object, path: str = "") -> list[SafetyFinding]: + """递归扫描已序列化的包,返回全部禁止项(路径 + 类别)。 + + 同时检查自由字典的键名(``shots[].metadata`` 是唯一的自由表面, + 因为其余模型均为 ``extra="forbid"``)。 + """ + findings: list[SafetyFinding] = [] + if isinstance(node, dict): + for key, item in node.items(): + child = f"{path}.{key}" if path else str(key) + if isinstance(key, str) and key.strip().lower() in FORBIDDEN_FREEFORM_KEYS: + findings.append(SafetyFinding(field_path=child, category="provider_native_field")) + continue + findings.extend(scan(item, child)) + elif isinstance(node, (list, tuple)): + for index, item in enumerate(node): + findings.extend(scan(item, f"{path}[{index}]")) + elif isinstance(node, str): + category = classify_string(node) + if category is not None: + findings.append(SafetyFinding(field_path=path or "", category=category)) + return findings + + +__all__ = [ + "SafetyFinding", + "classify_string", + "scan", + "PROVIDER_API_HOST_MARKERS", + "SIGNED_URL_MARKERS", + "FORBIDDEN_FREEFORM_KEYS", +] diff --git a/backend/app/crypto_animal_studio/domain/runtime.py b/backend/app/crypto_animal_studio/domain/runtime.py new file mode 100644 index 00000000..f8e47e80 --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/runtime.py @@ -0,0 +1,87 @@ +"""运行时长派生(domain 层,纯函数)。 + +唯一的时长真相来源:由镜头时长与(追加式)fact card 派生。 +``output.*_ms`` 只是可选断言,永不覆盖派生值。 + +取整策略只有一种:**round_half_up**(四舍五入、遇 .5 远离零),通过本模块的显式 helper +应用;**不得**依赖 Python 内建 ``round()``(银行家取整)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Iterable + +#: 断言与派生值之间允许的最大偏差(毫秒);恰好等于 50 ms 视为通过。 +RUNTIME_ASSERTION_TOLERANCE_MS: int = 50 + +#: fact card 计入总时长的 placement 取值。 +FACT_CARD_PLACEMENT_APPENDED: str = "append_after_shots" + + +def round_half_up(value: float | int | Decimal) -> int: + """把秒数换算后的数值按 round-half-up 取整为整数。 + + 参数: + value: 待取整的数值(通常是 ``seconds * 1000``)。 + 返回: + 整数(.5 一律远离零进位,例如 0.5→1、-0.5→-1)。 + 存在意义: + Python 内建 ``round()`` 使用银行家取整,会让 ``.5`` 边界结果与规范不一致。 + """ + return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def seconds_to_ms(seconds: float | int | Decimal) -> int: + """秒 → 整数毫秒(round-half-up)。""" + return round_half_up(Decimal(str(seconds)) * 1000) + + +@dataclass(slots=True) +class DerivedRuntime: + """派生出的权威时长信息。""" + + generated_ms: int + fact_card_ms: int + total_ms: int + per_shot_ms: tuple[int, ...] + + +def derive_runtime(shots: Iterable[Any], fact_card: Any | None = None) -> DerivedRuntime: + """派生权威时长。 + + 参数: + shots: 具备 ``duration_seconds`` 的镜头序列(顺序不影响求和)。 + fact_card: 可选 fact card 对象,需具备 ``duration_ms`` 与 ``placement``。 + 返回: + ``DerivedRuntime``:各镜头毫秒、生成footage毫秒、fact card 计入毫秒、总毫秒。 + 规则: + - 每个镜头**单独**应用 round_half_up 后再求和; + - 仅当 placement 为追加式时,fact card 才计入总时长。 + """ + per_shot = tuple(seconds_to_ms(shot.duration_seconds) for shot in shots) + generated_ms = sum(per_shot) + + fact_card_ms = 0 + if fact_card is not None: + placement = getattr(fact_card, "placement", FACT_CARD_PLACEMENT_APPENDED) + if placement == FACT_CARD_PLACEMENT_APPENDED: + fact_card_ms = int(getattr(fact_card, "duration_ms", 0) or 0) + + return DerivedRuntime( + generated_ms=generated_ms, + fact_card_ms=fact_card_ms, + total_ms=generated_ms + fact_card_ms, + per_shot_ms=per_shot, + ) + + +__all__ = [ + "round_half_up", + "seconds_to_ms", + "derive_runtime", + "DerivedRuntime", + "RUNTIME_ASSERTION_TOLERANCE_MS", + "FACT_CARD_PLACEMENT_APPENDED", +] diff --git a/backend/app/crypto_animal_studio/domain/webvtt.py b/backend/app/crypto_animal_studio/domain/webvtt.py new file mode 100644 index 00000000..81c30a3c --- /dev/null +++ b/backend/app/crypto_animal_studio/domain/webvtt.py @@ -0,0 +1,101 @@ +"""SubtitleTrack → WebVTT 的确定性渲染(纯函数,无 I/O)。 + +设计约束: +- **确定性**:同一 track 永远得到逐字节相同的输出(无时间戳、无 UUID、无字典序抖动); +- **保真**:语言标签、cue ID、cue 顺序、start_ms/end_ms、译文原文、镜头引用全部保留; +- 输出为 UTF-8 文本,行尾统一 ``\\n``(WebVTT 允许 LF;避免跨平台产生不同字节)。 + +镜头引用用 WebVTT 的 ``NOTE`` 注释承载(W3C WebVTT 允许在 cue 之间出现 NOTE 块), +因此既保留了信息,又不污染可渲染的字幕正文。 +""" + +from __future__ import annotations + +from typing import Any + +#: WebVTT 文件头。 +WEBVTT_HEADER = "WEBVTT" + +#: 产物 MIME 类型。 +WEBVTT_MIME_TYPE = "text/vtt" + + +def format_timestamp(milliseconds: int) -> str: + """把整数毫秒格式化为 WebVTT 时间戳 ``HH:MM:SS.mmm``。 + + 参数: + milliseconds: 非负整数毫秒(episode-absolute)。 + 返回: + 形如 ``00:00:02.400`` 的字符串。 + 异常: + ValueError:负值。 + """ + if milliseconds < 0: + raise ValueError(f"timestamp must be >= 0, got {milliseconds}") + total_seconds, millis = divmod(int(milliseconds), 1000) + minutes, seconds = divmod(total_seconds, 60) + hours, minutes = divmod(minutes, 60) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}" + + +def render_webvtt(track: Any) -> str: + """把一条 SubtitleTrack 渲染为确定性的 WebVTT 文本。 + + 输出结构:: + + WEBVTT + Language: zh-Hant + + NOTE shot=SC01 + c1 + 00:00:00.400 --> 00:00:02.000 + 突破了!我們回來了! + + 参数: + track: 具备 ``language_tag`` 与 ``cues``(含 cue_id/start_ms/end_ms/text/shot_id) + 的字幕轨对象。 + 返回: + UTF-8 WebVTT 文本(以单个换行结尾)。 + 异常: + ValueError:cue 列表为空,或某个 cue 的 ``end_ms`` 不大于 ``start_ms``。 + """ + cues = list(track.cues) + if not cues: + raise ValueError(f"subtitle track '{track.language_tag}' has no cues") + + blocks: list[str] = [f"{WEBVTT_HEADER}\nLanguage: {track.language_tag}"] + for index, cue in enumerate(cues): + if cue.end_ms <= cue.start_ms: + raise ValueError( + f"cue '{cue.cue_id}' has end_ms {cue.end_ms} <= start_ms {cue.start_ms}" + ) + lines: list[str] = [] + shot_id = getattr(cue, "shot_id", None) + speaker = getattr(cue, "speaker_character_key", None) + # 镜头 / 说话人引用放在 NOTE 里:保留信息且不进入可见字幕正文。 + note_parts = [f"cue={index + 1}"] + if shot_id: + note_parts.append(f"shot={shot_id}") + if speaker: + note_parts.append(f"speaker={speaker}") + lines.append("NOTE " + " ".join(note_parts)) + lines.append(str(cue.cue_id)) + lines.append(f"{format_timestamp(cue.start_ms)} --> {format_timestamp(cue.end_ms)}") + lines.append(cue.text) + blocks.append("\n".join(lines)) + + return "\n\n".join(blocks) + "\n" + + +def render_webvtt_bytes(track: Any) -> bytes: + """``render_webvtt`` 的 UTF-8 字节形式(不带 BOM)。""" + return render_webvtt(track).encode("utf-8") + + +__all__ = [ + "WEBVTT_HEADER", + "WEBVTT_MIME_TYPE", + "format_timestamp", + "render_webvtt", + "render_webvtt_bytes", +] diff --git a/backend/app/crypto_animal_studio/production/__init__.py b/backend/app/crypto_animal_studio/production/__init__.py new file mode 100644 index 00000000..26e84c22 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/__init__.py @@ -0,0 +1,6 @@ +"""CAS 生产流水线(Sprint 4 MVP 骨架)。 + +只包含确定性、可追溯的端到端生产骨架:ProductionJob/Shot/Artifact 持久化、 +供应商边界与 Mock 实现、确定性提示词、ArtifactManager、编排器与 manifest。 +本冲刺不接入任何真实 AI/FFmpeg 供应商,不使用 Celery/Redis/LLM。 +""" diff --git a/backend/app/crypto_animal_studio/production/artifact_manager.py b/backend/app/crypto_animal_studio/production/artifact_manager.py new file mode 100644 index 00000000..e46ef383 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/artifact_manager.py @@ -0,0 +1,233 @@ +"""ArtifactManager:产物路径、校验和与登记的唯一归口。 + +职责: +- **集中构造输出路径**(供应商不得自行编造路径); +- 安全创建目录; +- 计算校验和(SHA-256); +- 登记产物到数据库; +- 校验既有产物(DB 行 + 文件存在 + 校验和一致); +- 重试时复用仍然有效的既有产物。 + +路径约定(相对存储根):: + + cas/productions/{project_id}/{episode_id}/{job_id}/ + manifest.json + shots/{sequence}-{shot_id}/{prompt.json,image/,video/,voice/,subtitle/} + final/final_video.txt + +数据库中保存**相对路径**(POSIX 风格),便于跨平台(含 Windows)迁移与比对。 +""" + +from __future__ import annotations + +import hashlib +import os +import re +import uuid +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.production.enums import ArtifactType, Stage +from app.crypto_animal_studio.production.models import CasProductionArtifact, CasProductionJob, CasProductionShot +from app.services.common import create_and_refresh + +#: 存储根目录环境变量;未设置时回落到仓库根 ``storage/``。 +STORAGE_ROOT_ENV = "CAS_STORAGE_ROOT" +_BACKEND_ROOT = Path(__file__).resolve().parents[4] # .../backend +_DEFAULT_STORAGE_ROOT = _BACKEND_ROOT.parent / "storage" + +_SAFE_SEGMENT = re.compile(r"[^A-Za-z0-9._-]+") + + +def default_storage_root() -> Path: + """返回默认存储根(可用 ``CAS_STORAGE_ROOT`` 覆盖)。""" + configured = os.environ.get(STORAGE_ROOT_ENV, "").strip() + return Path(configured) if configured else _DEFAULT_STORAGE_ROOT + + +def sanitize_segment(value: str) -> str: + """把任意标识清洗为安全的单层路径片段(防路径穿越/非法字符)。""" + cleaned = _SAFE_SEGMENT.sub("-", (value or "").strip()).strip("-._") + return cleaned or "unnamed" + + +def file_checksum(path: Path) -> str: + """计算文件的 SHA-256 十六进制摘要。""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +class ArtifactManager: + """一次生产运行的产物管理器。""" + + def __init__(self, db: AsyncSession, job: CasProductionJob, *, storage_root: Path | None = None) -> None: + """绑定会话与任务,并解析存储根。""" + self._db = db + self._job = job + self.storage_root = Path(storage_root) if storage_root else default_storage_root() + + # --- 路径构造 ---------------------------------------------------- # + @property + def job_relpath(self) -> str: + """任务输出根(相对存储根,POSIX 风格)。""" + return "/".join( + [ + "cas", + "productions", + sanitize_segment(self._job.project_id), + sanitize_segment(self._job.episode_id), + sanitize_segment(self._job.id), + ] + ) + + @property + def job_dir(self) -> Path: + """任务输出根的绝对路径。""" + return self.storage_root / Path(self.job_relpath) + + def shot_relpath(self, sequence: int, shot_id: str) -> str: + """镜头目录(相对存储根)。""" + return f"{self.job_relpath}/shots/{int(sequence)}-{sanitize_segment(shot_id)}" + + def artifact_relpath(self, artifact_type: ArtifactType, *, sequence: int | None = None, shot_id: str | None = None) -> str: + """按产物类型返回其相对路径(唯一归口,供应商不得自造)。""" + if artifact_type is ArtifactType.manifest: + return f"{self.job_relpath}/manifest.json" + if artifact_type is ArtifactType.final_video: + return f"{self.job_relpath}/final/final_video.txt" + if sequence is None or shot_id is None: + raise ValueError(f"artifact type {artifact_type.value} requires sequence and shot_id") + base = self.shot_relpath(sequence, shot_id) + if artifact_type is ArtifactType.prompt: + return f"{base}/prompt.json" + mapping = { + ArtifactType.image: "image/image.txt", + ArtifactType.video: "video/video.txt", + ArtifactType.voice: "voice/voice.txt", + ArtifactType.subtitle: "subtitle/subtitle.txt", + ArtifactType.music: "music/music.txt", + ArtifactType.log: "log/log.txt", + } + if artifact_type not in mapping: + raise ValueError(f"unsupported artifact type: {artifact_type.value}") + return f"{base}/{mapping[artifact_type]}" + + def abs_path(self, relpath: str) -> Path: + """把相对路径解析为绝对路径。""" + return self.storage_root / Path(relpath) + + def ensure_parent(self, relpath: str) -> Path: + """确保目标文件的父目录存在,返回绝对路径。""" + target = self.abs_path(relpath) + target.parent.mkdir(parents=True, exist_ok=True) + return target + + # --- 产物登记 / 校验 / 复用 ---------------------------------------- # + async def find_existing(self, artifact_type: ArtifactType, *, production_shot_id: str | None = None) -> CasProductionArtifact | None: + """查找该任务(可选镜头)下指定类型的既有产物记录。""" + stmt = select(CasProductionArtifact).where( + CasProductionArtifact.job_id == self._job.id, + CasProductionArtifact.artifact_type == artifact_type.value, + ) + stmt = stmt.where( + CasProductionArtifact.production_shot_id == production_shot_id + if production_shot_id is not None + else CasProductionArtifact.production_shot_id.is_(None) + ) + return (await self._db.execute(stmt)).scalars().first() + + def is_valid(self, artifact: CasProductionArtifact) -> bool: + """产物是否仍然有效:文件存在且校验和一致。""" + path = self.abs_path(artifact.file_path) + if not path.is_file(): + return False + if not artifact.checksum: + return False + return file_checksum(path) == artifact.checksum + + async def find_valid(self, artifact_type: ArtifactType, *, production_shot_id: str | None = None) -> CasProductionArtifact | None: + """返回仍然有效的既有产物(用于重试复用),否则 None。""" + existing = await self.find_existing(artifact_type, production_shot_id=production_shot_id) + if existing is not None and self.is_valid(existing): + return existing + return None + + async def register( + self, + *, + artifact_type: ArtifactType, + stage: Stage, + relpath: str, + mime_type: str, + provider: str = "", + provider_model: str = "", + production_shot_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> CasProductionArtifact: + """登记(或就地更新)一条产物记录,并计算校验和。""" + checksum = file_checksum(self.abs_path(relpath)) + existing = await self.find_existing(artifact_type, production_shot_id=production_shot_id) + if existing is not None: + existing.stage = stage.value + existing.provider = provider + existing.provider_model = provider_model + existing.file_path = relpath + existing.mime_type = mime_type + existing.checksum = checksum + existing.metadata_json = metadata or {} + await self._db.flush() + return existing + artifact = CasProductionArtifact( + id=str(uuid.uuid4()), + job_id=self._job.id, + production_shot_id=production_shot_id, + artifact_type=artifact_type.value, + stage=stage.value, + provider=provider, + provider_model=provider_model, + file_path=relpath, + mime_type=mime_type, + checksum=checksum, + metadata_json=metadata or {}, + ) + return await create_and_refresh(self._db, artifact) + + async def write_text_artifact( + self, + *, + artifact_type: ArtifactType, + stage: Stage, + content: str, + shot: CasProductionShot | None = None, + mime_type: str = "text/plain", + provider: str = "cas", + provider_model: str = "deterministic-v0", + metadata: dict[str, Any] | None = None, + ) -> CasProductionArtifact: + """由编排层直接写出的文本产物(如 prompt.json、字幕)并登记。""" + relpath = self.artifact_relpath( + artifact_type, + sequence=shot.sequence if shot else None, + shot_id=shot.source_shot_id if shot else None, + ) + target = self.ensure_parent(relpath) + target.write_text(content, encoding="utf-8", newline="\n") + return await self.register( + artifact_type=artifact_type, + stage=stage, + relpath=relpath, + mime_type=mime_type, + provider=provider, + provider_model=provider_model, + production_shot_id=shot.id if shot else None, + metadata=metadata, + ) + + +__all__ = ["ArtifactManager", "default_storage_root", "file_checksum", "sanitize_segment", "STORAGE_ROOT_ENV"] diff --git a/backend/app/crypto_animal_studio/production/cli.py b/backend/app/crypto_animal_studio/production/cli.py new file mode 100644 index 00000000..a2d6cae5 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/cli.py @@ -0,0 +1,87 @@ +"""CAS 生产 CLI(同步执行,Windows PowerShell 兼容)。 + +用法:: + + uv run python -m app.crypto_animal_studio.production.cli run \ + --project-id demo-project \ + --episode-package samples/cas/demo_episode.json \ + --provider-mode mock + +仅使用标准库 argparse(不新增依赖)。输出:状态、job id、manifest 路径、成片路径。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings +from app.core.db import Base +from app.crypto_animal_studio.production.artifact_manager import ArtifactManager, default_storage_root +from app.crypto_animal_studio.production.enums import ArtifactType +from app.crypto_animal_studio.production.orchestrator import start_production +from app.crypto_animal_studio.production.providers.mock import build_mock_bundle +from app.crypto_animal_studio.application.parsing import parse_episode_package + + +async def _run(project_id: str, package_path: Path, provider_mode: str, storage_root: Path | None, create_tables: bool) -> int: + """执行一次生产并打印结果;返回进程退出码。""" + # 走版本分派:v1 行为完全不变,同时接受 v1.1 文档;未知版本显式失败。 + package = parse_episode_package(json.loads(package_path.read_text(encoding="utf-8"))) + + engine = create_async_engine(settings.database_url) + if create_tables: + # 仅为副作用导入:把 ORM 模型注册到 Base.metadata,供 create_all 建表使用。 + # 与 app/core/db.py::init_db 保持同一写法。 + import app.crypto_animal_studio.production.models # noqa: F401 # pylint: disable=unused-import + import app.crypto_animal_studio.domain.import_ledger # noqa: F401 # pylint: disable=unused-import + import app.models.studio # noqa: F401 # pylint: disable=unused-import + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as db: + job = await start_production( + db, project_id=project_id, package=package, providers=build_mock_bundle(), provider_mode=provider_mode, storage_root=storage_root + ) + manager = ArtifactManager(db, job, storage_root=storage_root) + manifest_rel = manager.artifact_relpath(ArtifactType.manifest) + final_rel = manager.artifact_relpath(ArtifactType.final_video) + await db.commit() + + root = storage_root or default_storage_root() + print(f"status: {job.status}") + print(f"job_id: {job.id}") + print(f"manifest: {root / Path(manifest_rel)}") + print(f"final_output: {root / Path(final_rel)}") + if job.error_message: + print(f"error: {job.error_message}", file=sys.stderr) + await engine.dispose() + return 0 if job.status == "completed" else 1 + + +def main(argv: list[str] | None = None) -> int: + """CLI 入口。""" + parser = argparse.ArgumentParser(prog="cas-production", description="CAS production pipeline (mock providers)") + sub = parser.add_subparsers(dest="command", required=True) + run_cmd = sub.add_parser("run", help="run a production job from an EpisodePackage JSON file") + run_cmd.add_argument("--project-id", required=True) + run_cmd.add_argument("--episode-package", required=True, type=Path) + run_cmd.add_argument("--provider-mode", default="mock", choices=["mock"]) + run_cmd.add_argument("--storage-root", type=Path, default=None, help="override storage root (defaults to /storage)") + run_cmd.add_argument("--create-tables", action="store_true", help="create tables if missing (dev/SQLite convenience)") + + args = parser.parse_args(argv) + if args.command == "run": + return asyncio.run(_run(args.project_id, args.episode_package, args.provider_mode, args.storage_root, args.create_tables)) + return 2 + + +if __name__ == "__main__": # pragma: no cover - 进程入口 + raise SystemExit(main()) diff --git a/backend/app/crypto_animal_studio/production/enums.py b/backend/app/crypto_animal_studio/production/enums.py new file mode 100644 index 00000000..de53ef73 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/enums.py @@ -0,0 +1,63 @@ +"""CAS 生产流水线枚举(CAS 本地定义,不复用/污染 Jellyfish 业务枚举)。""" + +from __future__ import annotations + +from enum import Enum + + +class JobStatus(str, Enum): + """生产任务状态。""" + + pending = "pending" + running = "running" + completed = "completed" + failed = "failed" + cancelled = "cancelled" + + +class Stage(str, Enum): + """生产阶段(按流水线顺序定义)。""" + + validate = "validate" + prompt_build = "prompt_build" + image_generation = "image_generation" + video_generation = "video_generation" + audio_generation = "audio_generation" + subtitle_generation = "subtitle_generation" + composition = "composition" + finalize = "finalize" + + +class ArtifactType(str, Enum): + """产物类型。""" + + prompt = "prompt" + image = "image" + video = "video" + voice = "voice" + subtitle = "subtitle" + music = "music" + manifest = "manifest" + final_video = "final_video" + log = "log" + + +#: 流水线阶段顺序(重试时据此判断“失败阶段及其之后”)。 +STAGE_ORDER: tuple[Stage, ...] = ( + Stage.validate, + Stage.prompt_build, + Stage.image_generation, + Stage.video_generation, + Stage.audio_generation, + Stage.subtitle_generation, + Stage.composition, + Stage.finalize, +) + + +def stage_index(stage: Stage) -> int: + """返回阶段在流水线中的序号(用于比较先后)。""" + return STAGE_ORDER.index(stage) + + +__all__ = ["JobStatus", "Stage", "ArtifactType", "STAGE_ORDER", "stage_index"] diff --git a/backend/app/crypto_animal_studio/production/models.py b/backend/app/crypto_animal_studio/production/models.py new file mode 100644 index 00000000..26f4745b --- /dev/null +++ b/backend/app/crypto_animal_studio/production/models.py @@ -0,0 +1,100 @@ +"""CAS 生产流水线 ORM 模型(生产状态,独立于创作域模型)。 + +边界说明: +- 这些表**只记录生产运行状态与产物**,不复制 Jellyfish 的 Project/Chapter/Shot/Asset 等创作实体; + ``ProductionShot.source_shot_id`` 只保存 EpisodePackage 中的 shot_id(弱引用), + 绝不取代或重复创作侧的 Shot 模型。 +- 复用 Jellyfish 的 ``Base`` 与 ``TimestampMixin``;表结构由 + `backend/sql/010-add-cas-production-tables.sql` 迁移创建。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db import Base +from app.models.base import TimestampMixin + + +class CasProductionJob(Base, TimestampMixin): + """一次生产运行(对应一个 EpisodePackage)。""" + + __tablename__ = "cas_production_jobs" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="任务 ID(UUID)") + project_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="项目 ID") + episode_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="Episode ID") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", comment="任务状态") + current_stage: Mapped[str] = mapped_column(String(32), nullable=False, default="validate", comment="当前阶段") + episode_package_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="EpisodePackage 规范化哈希") + provider_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="mock", comment="供应商模式(本冲刺仅 mock)") + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, comment="开始时间") + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, comment="完成时间") + error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="错误信息") + output_path: Mapped[str] = mapped_column(String(1024), nullable=False, default="", comment="任务输出根目录") + + shots: Mapped[list["CasProductionShot"]] = relationship( + back_populates="job", cascade="all, delete-orphan", passive_deletes=True, order_by="CasProductionShot.sequence" + ) + artifacts: Mapped[list["CasProductionArtifact"]] = relationship( + back_populates="job", cascade="all, delete-orphan", passive_deletes=True + ) + + __table_args__ = (Index("ix_cas_prod_jobs_project_episode", "project_id", "episode_id"),) + + +class CasProductionShot(Base, TimestampMixin): + """单个镜头的生产状态(仅生产态,不替代创作 Shot)。""" + + __tablename__ = "cas_production_shots" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="生产镜头 ID(UUID)") + job_id: Mapped[str] = mapped_column( + String(64), ForeignKey("cas_production_jobs.id", ondelete="CASCADE"), nullable=False, index=True, comment="所属任务" + ) + source_shot_id: Mapped[str] = mapped_column(String(255), nullable=False, comment="EpisodePackage 中的 shot_id(弱引用)") + sequence: Mapped[int] = mapped_column(Integer, nullable=False, comment="镜头顺序") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", comment="镜头生产状态") + current_stage: Mapped[str] = mapped_column(String(32), nullable=False, default="validate", comment="当前阶段") + image_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="图像提示词") + negative_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="反向提示词") + video_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="视频提示词") + duration_seconds: Mapped[float] = mapped_column(nullable=False, default=0.0, comment="镜头时长(秒)") + error_message: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="错误信息") + + job: Mapped["CasProductionJob"] = relationship(back_populates="shots") + + __table_args__ = (Index("ix_cas_prod_shots_job_sequence", "job_id", "sequence"),) + + +class CasProductionArtifact(Base, TimestampMixin): + """一次生产产生的产物记录(Artifact First 的落点)。""" + + __tablename__ = "cas_production_artifacts" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="产物 ID(UUID)") + job_id: Mapped[str] = mapped_column( + String(64), ForeignKey("cas_production_jobs.id", ondelete="CASCADE"), nullable=False, index=True, comment="所属任务" + ) + production_shot_id: Mapped[str | None] = mapped_column( + String(64), ForeignKey("cas_production_shots.id", ondelete="CASCADE"), nullable=True, index=True, comment="所属生产镜头(可空:任务级产物)" + ) + artifact_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="产物类型") + stage: Mapped[str] = mapped_column(String(32), nullable=False, comment="产生该产物的阶段") + provider: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="供应商标识") + provider_model: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="供应商模型标识") + file_path: Mapped[str] = mapped_column(String(1024), nullable=False, comment="产物文件路径(相对存储根)") + mime_type: Mapped[str] = mapped_column(String(128), nullable=False, default="", comment="MIME 类型") + checksum: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="文件 SHA-256") + metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict, comment="附加元信息") + + job: Mapped["CasProductionJob"] = relationship(back_populates="artifacts") + + __table_args__ = (Index("ix_cas_prod_artifacts_job_type", "job_id", "artifact_type"),) + + +__all__ = ["CasProductionJob", "CasProductionShot", "CasProductionArtifact"] diff --git a/backend/app/crypto_animal_studio/production/orchestrator.py b/backend/app/crypto_animal_studio/production/orchestrator.py new file mode 100644 index 00000000..98192b48 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/orchestrator.py @@ -0,0 +1,383 @@ +"""CAS 生产编排器(同步执行、确定性、可重试)。 + +流水线:validate → prompt_build → image_generation → video_generation → +audio_generation → subtitle_generation → composition → finalize。 + +失败语义: +- 标记当前 ProductionShot 失败(若该阶段属于某镜头); +- 标记 ProductionJob 失败,并记录**可执行的**错误信息与失败阶段; +- 保留所有已成功产物(不回滚文件,不删除已登记产物)。 + +重试语义: +- 从失败阶段重新开始,重跑该阶段及其之后的所有阶段; +- 更早阶段的产物在「DB 有记录 + 文件存在 + 校验和一致」时**复用**,不重新生成。 + +本模块不含任何供应商细节(通过 ProviderBundle 注入),不调用 LLM/Celery/Redis。 +""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.application.hashing import canonical_payload_hash +from app.crypto_animal_studio.production.artifact_manager import ArtifactManager +from app.crypto_animal_studio.production.enums import ArtifactType, JobStatus, Stage, STAGE_ORDER, stage_index +from app.crypto_animal_studio.production.models import CasProductionArtifact, CasProductionJob, CasProductionShot +from app.crypto_animal_studio.production.prompt_builder import build_shot_prompts +from app.crypto_animal_studio.production.providers.base import ProviderBundle +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage +from app.services.common import create_and_refresh + + +class ProductionError(Exception): + """生产流程的领域异常基类。""" + + +class JobNotFoundError(ProductionError): + """任务不存在。""" + + +class PackageMismatchError(ProductionError): + """重试时提供的 EpisodePackage 与原任务不一致。""" + + +def _utcnow() -> datetime: + """返回带时区的当前时间(UTC)。""" + return datetime.now(timezone.utc) + + +async def create_job( + db: AsyncSession, *, project_id: str, package: EpisodePackage, provider_mode: str = "mock", storage_root: Path | None = None +) -> CasProductionJob: + """创建一个 ProductionJob 及其 ProductionShots(状态 pending)。""" + job = CasProductionJob( + id=str(uuid.uuid4()), + project_id=project_id, + episode_id=package.episode_id, + status=JobStatus.pending.value, + current_stage=Stage.validate.value, + episode_package_hash=canonical_payload_hash(package), + provider_mode=provider_mode, + ) + await create_and_refresh(db, job) + + manager = ArtifactManager(db, job, storage_root=storage_root) + job.output_path = manager.job_relpath + await db.flush() + + for shot in sorted(package.shots, key=lambda s: s.sequence): + await create_and_refresh( + db, + CasProductionShot( + id=str(uuid.uuid4()), + job_id=job.id, + source_shot_id=shot.shot_id, + sequence=shot.sequence, + status=JobStatus.pending.value, + current_stage=Stage.validate.value, + duration_seconds=float(shot.duration_seconds), + ), + ) + return job + + +async def _load_shots(db: AsyncSession, job: CasProductionJob) -> list[CasProductionShot]: + """按 sequence 升序加载任务下的生产镜头。""" + stmt = select(CasProductionShot).where(CasProductionShot.job_id == job.id).order_by(CasProductionShot.sequence) + return list((await db.execute(stmt)).scalars().all()) + + +async def _load_artifacts(db: AsyncSession, job: CasProductionJob) -> list[CasProductionArtifact]: + """加载任务下全部产物记录。""" + stmt = select(CasProductionArtifact).where(CasProductionArtifact.job_id == job.id) + return list((await db.execute(stmt)).scalars().all()) + + +async def run_job( + db: AsyncSession, + *, + job: CasProductionJob, + package: EpisodePackage, + providers: ProviderBundle, + storage_root: Path | None = None, + start_stage: Stage | None = None, +) -> CasProductionJob: + """执行(或从 ``start_stage`` 续跑)一个生产任务。 + + 返回最终的 job(成功为 completed,失败为 failed,且失败时保留已成功产物)。 + """ + manager = ArtifactManager(db, job, storage_root=storage_root) + shots = await _load_shots(db, job) + begin = stage_index(start_stage or Stage.validate) + + job.status = JobStatus.running.value + if job.started_at is None: + job.started_at = _utcnow() + job.error_message = "" + job.output_path = manager.job_relpath + await db.flush() + + current_stage = STAGE_ORDER[begin] + current_shot: CasProductionShot | None = None + try: + # 无论从哪个阶段续跑,都必须先校验 package 与原任务一致(防止重试时换了内容)。 + if canonical_payload_hash(package) != job.episode_package_hash: + current_stage = Stage.validate + raise PackageMismatchError("episode_package does not match the original job payload hash") + + for stage in STAGE_ORDER[begin:]: + current_stage = stage + job.current_stage = stage.value + await db.flush() + + if stage is Stage.validate: + pass # 已在循环前完成哈希校验 + + elif stage is Stage.prompt_build: + for shot_row in shots: + current_shot = shot_row + spec = _find_shot(package, shot_row.source_shot_id) + prompts = build_shot_prompts(package, spec) + shot_row.image_prompt = prompts.image_prompt + shot_row.negative_prompt = prompts.negative_prompt + shot_row.video_prompt = prompts.video_prompt + shot_row.duration_seconds = float(spec.duration_seconds) + shot_row.current_stage = stage.value + await db.flush() + if await manager.find_valid(ArtifactType.prompt, production_shot_id=shot_row.id) is None: + await manager.write_text_artifact( + artifact_type=ArtifactType.prompt, + stage=stage, + content=json.dumps(prompts.to_dict(), ensure_ascii=False, indent=2, sort_keys=True) + "\n", + shot=shot_row, + mime_type="application/json", + ) + current_shot = None + + elif stage in (Stage.image_generation, Stage.video_generation, Stage.audio_generation, Stage.subtitle_generation): + for shot_row in shots: + current_shot = shot_row + shot_row.current_stage = stage.value + await db.flush() + await _run_shot_stage(manager, stage, shot_row, package, providers) + current_shot = None + + elif stage is Stage.composition: + if await manager.find_valid(ArtifactType.final_video) is None: + relpath = manager.artifact_relpath(ArtifactType.final_video) + target = manager.ensure_parent(relpath) + shot_inputs = [ + { + "sequence": s.sequence, + "shot_id": s.source_shot_id, + "video": manager.artifact_relpath(ArtifactType.video, sequence=s.sequence, shot_id=s.source_shot_id), + "voice": manager.artifact_relpath(ArtifactType.voice, sequence=s.sequence, shot_id=s.source_shot_id), + } + for s in shots + ] + generated = providers.composer.compose( + target_path=target, shot_inputs=shot_inputs, context={"episode_id": job.episode_id, "job_id": job.id} + ) + await manager.register( + artifact_type=ArtifactType.final_video, + stage=stage, + relpath=relpath, + mime_type=generated.mime_type, + provider=generated.provider, + provider_model=generated.provider_model, + metadata=generated.metadata, + ) + + elif stage is Stage.finalize: + for shot_row in shots: + shot_row.status = JobStatus.completed.value + shot_row.current_stage = Stage.finalize.value + shot_row.error_message = "" + job.status = JobStatus.completed.value + job.completed_at = _utcnow() + await db.flush() + await _write_manifest(db, manager, job, shots, providers) + + return job + + except Exception as exc: # noqa: BLE001 - 转换为可持久化的失败状态 + message = f"{type(exc).__name__}: {exc}" + if current_shot is not None: + current_shot.status = JobStatus.failed.value + current_shot.current_stage = current_stage.value + current_shot.error_message = message + job.status = JobStatus.failed.value + job.current_stage = current_stage.value + job.error_message = message + await db.flush() + # 失败时也写一份 manifest,保证可追溯(已成功产物全部保留) + try: + await _write_manifest(db, manager, job, shots, providers) + except Exception: # noqa: BLE001 - manifest 写入失败不得掩盖原始错误 + pass + return job + + +def _find_shot(package: EpisodePackage, shot_id: str): + """在 EpisodePackage 中按 shot_id 定位镜头规格。""" + for shot in package.shots: + if shot.shot_id == shot_id: + return shot + raise ProductionError(f"shot '{shot_id}' not found in episode_package") + + +async def _run_shot_stage( + manager: ArtifactManager, stage: Stage, shot_row: CasProductionShot, package: EpisodePackage, providers: ProviderBundle +) -> None: + """执行单镜头的某个生成阶段(存在有效产物时复用)。""" + type_by_stage = { + Stage.image_generation: ArtifactType.image, + Stage.video_generation: ArtifactType.video, + Stage.audio_generation: ArtifactType.voice, + Stage.subtitle_generation: ArtifactType.subtitle, + } + artifact_type = type_by_stage[stage] + if await manager.find_valid(artifact_type, production_shot_id=shot_row.id) is not None: + return # 复用既有有效产物 + + spec = _find_shot(package, shot_row.source_shot_id) + prompts = build_shot_prompts(package, spec) + context = {"shot_id": shot_row.source_shot_id, "sequence": shot_row.sequence, "duration_seconds": shot_row.duration_seconds} + relpath = manager.artifact_relpath(artifact_type, sequence=shot_row.sequence, shot_id=shot_row.source_shot_id) + + if stage is Stage.subtitle_generation: + await manager.write_text_artifact( + artifact_type=ArtifactType.subtitle, stage=stage, content=prompts.subtitle_text + "\n", shot=shot_row, mime_type="text/plain" + ) + return + + target = manager.ensure_parent(relpath) + if stage is Stage.image_generation: + generated = providers.image.generate_image( + target_path=target, prompt=prompts.image_prompt, negative_prompt=prompts.negative_prompt, context=context + ) + elif stage is Stage.video_generation: + generated = providers.video.generate_video(target_path=target, prompt=prompts.video_prompt, context=context) + else: # Stage.audio_generation + generated = providers.voice.generate_voice(target_path=target, text=prompts.voice_text, context=context) + + await manager.register( + artifact_type=artifact_type, + stage=stage, + relpath=relpath, + mime_type=generated.mime_type, + provider=generated.provider, + provider_model=generated.provider_model, + production_shot_id=shot_row.id, + metadata=generated.metadata, + ) + + +async def _write_manifest( + db: AsyncSession, manager: ArtifactManager, job: CasProductionJob, shots: list[CasProductionShot], providers: ProviderBundle +) -> CasProductionArtifact: + """写出 manifest.json(全量可追溯信息)并登记为产物。""" + artifacts = await _load_artifacts(db, job) + final_artifact = next((a for a in artifacts if a.artifact_type == ArtifactType.final_video.value), None) + errors = [{"scope": "job", "stage": job.current_stage, "message": job.error_message}] if job.error_message else [] + errors += [ + {"scope": "shot", "shot_id": s.source_shot_id, "sequence": s.sequence, "stage": s.current_stage, "message": s.error_message} + for s in shots + if s.error_message + ] + + manifest = { + "job_id": job.id, + "project_id": job.project_id, + "episode_id": job.episode_id, + "status": job.status, + "episode_package_hash": job.episode_package_hash, + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + "shots": [ + { + "id": s.id, + "source_shot_id": s.source_shot_id, + "sequence": s.sequence, + "status": s.status, + "current_stage": s.current_stage, + "duration_seconds": s.duration_seconds, + "error_message": s.error_message, + } + for s in shots + ], + "artifacts": sorted( + ( + { + "id": a.id, + "production_shot_id": a.production_shot_id, + "artifact_type": a.artifact_type, + "stage": a.stage, + "provider": a.provider, + "provider_model": a.provider_model, + "file_path": a.file_path, + "mime_type": a.mime_type, + "checksum": a.checksum, + } + for a in artifacts + if a.artifact_type != ArtifactType.manifest.value + ), + key=lambda item: (item["artifact_type"], item["file_path"]), + ), + "providers": providers.describe(), + "errors": errors, + "final_output": final_artifact.file_path if final_artifact else None, + } + return await manager.write_text_artifact( + artifact_type=ArtifactType.manifest, + stage=Stage.finalize, + content=json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + mime_type="application/json", + ) + + +async def start_production( + db: AsyncSession, + *, + project_id: str, + package: EpisodePackage, + providers: ProviderBundle, + provider_mode: str = "mock", + storage_root: Path | None = None, +) -> CasProductionJob: + """创建并同步执行一次完整生产(每次调用创建新任务)。""" + job = await create_job(db, project_id=project_id, package=package, provider_mode=provider_mode, storage_root=storage_root) + return await run_job(db, job=job, package=package, providers=providers, storage_root=storage_root) + + +async def retry_production( + db: AsyncSession, *, job_id: str, package: EpisodePackage, providers: ProviderBundle, storage_root: Path | None = None +) -> CasProductionJob: + """从失败阶段重试:重跑该阶段及其之后,复用更早的有效产物。""" + job = await db.get(CasProductionJob, job_id) + if job is None: + raise JobNotFoundError(f"production job not found: {job_id}") + start = Stage(job.current_stage) if job.status == JobStatus.failed.value else Stage.validate + for shot_row in await _load_shots(db, job): + if shot_row.status == JobStatus.failed.value: + shot_row.status = JobStatus.pending.value + shot_row.error_message = "" + await db.flush() + return await run_job(db, job=job, package=package, providers=providers, storage_root=storage_root, start_stage=start) + + +__all__ = [ + "create_job", + "run_job", + "start_production", + "retry_production", + "ProductionError", + "JobNotFoundError", + "PackageMismatchError", +] diff --git a/backend/app/crypto_animal_studio/production/prompt_builder.py b/backend/app/crypto_animal_studio/production/prompt_builder.py new file mode 100644 index 00000000..fefa9514 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/prompt_builder.py @@ -0,0 +1,106 @@ +"""确定性 PromptBuilder v0(不调用任何 LLM)。 + +对每个 EpisodePackage 镜头生成 image_prompt / negative_prompt / video_prompt / +voice_text / subtitle_text。规则纯函数式、字段顺序固定,因此**相同 EpisodePackage 恒等产出 +相同提示词**(由测试保证)。 +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + +from app.crypto_animal_studio.domain import mapping +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage, Shot + +#: 全局反向提示词基线(确定性常量)。 +BASE_NEGATIVE_PROMPT = "low quality, blurry, deformed, extra limbs, watermark, text overlay, logo, subtitles" + + +@dataclass(slots=True) +class ShotPrompts: + """单镜头的一组确定性提示词。""" + + shot_id: str + sequence: int + image_prompt: str + negative_prompt: str + video_prompt: str + voice_text: str + subtitle_text: str + + def to_dict(self) -> dict: + """转为可 JSON 序列化的 dict(写入 prompt.json)。""" + return asdict(self) + + +def _character_names(package: EpisodePackage, shot: Shot) -> list[str]: + """按镜头 character_keys 的给定顺序解析角色展示名(缺失则回退为 key)。""" + by_key = {c.character_key: c.display_name for c in package.characters} + return [by_key.get(key, key) for key in shot.character_keys] + + +def _scene_name(package: EpisodePackage, shot: Shot) -> str: + """解析场景展示名(无场景返回空串)。""" + if shot.scene_key is None: + return "" + for scene in package.assets.scenes: + if scene.scene_key == shot.scene_key: + return scene.display_name or scene.scene_key + return shot.scene_key + + +def build_shot_prompts(package: EpisodePackage, shot: Shot) -> ShotPrompts: + """为单个镜头构建确定性提示词。 + + 组装顺序固定:视觉风格 → 场景 → 角色 → 动作 → 相机 → 既有 image_prompt。 + """ + shot_type, angle, movement, _ = mapping.resolve_camera(shot.camera) + characters = _character_names(package, shot) + scene = _scene_name(package, shot) + + image_parts = [ + f"style: {package.creative_direction.visual_style}".strip(), + f"scene: {scene}" if scene else "", + f"characters: {', '.join(characters)}" if characters else "", + f"action: {shot.action}" if shot.action else "", + f"camera: {shot_type}/{angle}", + shot.image_prompt, + ] + image_prompt = " | ".join(part for part in image_parts if part) + + negative_prompt = shot.negative_prompt or BASE_NEGATIVE_PROMPT + + video_parts = [ + f"motion: {movement}", + f"duration: {shot.duration_seconds}s", + f"action: {shot.action}" if shot.action else "", + shot.video_prompt, + ] + video_prompt = " | ".join(part for part in video_parts if part) + + # 语音/字幕:按 order 升序拼接对白,说话人用展示名(无则用 key) + by_key = {c.character_key: c.display_name for c in package.characters} + dialogue_lines = [ + f"{by_key.get(line.character_key, line.character_key) if line.character_key else '—'}: {line.text}" + for line in sorted(shot.dialogue, key=lambda d: d.order) + ] + voice_text = "\n".join(dialogue_lines) + subtitle_text = "\n".join(line.text for line in sorted(shot.dialogue, key=lambda d: d.order)) + + return ShotPrompts( + shot_id=shot.shot_id, + sequence=shot.sequence, + image_prompt=image_prompt, + negative_prompt=negative_prompt, + video_prompt=video_prompt, + voice_text=voice_text, + subtitle_text=subtitle_text, + ) + + +def build_all_prompts(package: EpisodePackage) -> list[ShotPrompts]: + """按 sequence 升序为整包构建提示词(确定性)。""" + return [build_shot_prompts(package, shot) for shot in sorted(package.shots, key=lambda s: s.sequence)] + + +__all__ = ["ShotPrompts", "build_shot_prompts", "build_all_prompts", "BASE_NEGATIVE_PROMPT"] diff --git a/backend/app/crypto_animal_studio/production/providers/__init__.py b/backend/app/crypto_animal_studio/production/providers/__init__.py new file mode 100644 index 00000000..6aff76cd --- /dev/null +++ b/backend/app/crypto_animal_studio/production/providers/__init__.py @@ -0,0 +1,33 @@ +"""生产供应商边界与 Mock 实现。""" + +from app.crypto_animal_studio.production.providers.base import ( + Composer, + GeneratedArtifact, + ImageProvider, + ProviderBundle, + VideoProvider, + VoiceProvider, +) +from app.crypto_animal_studio.production.providers.mock import ( + MockComposer, + MockImageProvider, + MockProviderFailure, + MockVideoProvider, + MockVoiceProvider, + build_mock_bundle, +) + +__all__ = [ + "GeneratedArtifact", + "ImageProvider", + "VideoProvider", + "VoiceProvider", + "Composer", + "ProviderBundle", + "MockImageProvider", + "MockVideoProvider", + "MockVoiceProvider", + "MockComposer", + "MockProviderFailure", + "build_mock_bundle", +] diff --git a/backend/app/crypto_animal_studio/production/providers/base.py b/backend/app/crypto_animal_studio/production/providers/base.py new file mode 100644 index 00000000..9fff1f10 --- /dev/null +++ b/backend/app/crypto_animal_studio/production/providers/base.py @@ -0,0 +1,95 @@ +"""生产供应商边界(adapters)。 + +核心编排代码只依赖这些抽象与 ``GeneratedArtifact``,**不得**出现具体供应商的 +模型名、SDK 或 API 细节。真实供应商在后续冲刺以适配器形式接入。 + +约定:供应商**不自行编造文件路径**——目标路径由 ArtifactManager 计算后传入。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass(slots=True) +class GeneratedArtifact: + """所有供应商的统一返回结果。""" + + file_path: Path + mime_type: str + provider: str + provider_model: str + metadata: dict[str, Any] = field(default_factory=dict) + + +class ImageProvider(ABC): + """图像生成供应商边界。""" + + name: str = "base-image" + model: str = "unknown" + + @abstractmethod + def generate_image(self, *, target_path: Path, prompt: str, negative_prompt: str, context: dict[str, Any]) -> GeneratedArtifact: + """在 ``target_path`` 生成一张图像产物并返回统一结果。""" + raise NotImplementedError + + +class VideoProvider(ABC): + """视频生成供应商边界。""" + + name: str = "base-video" + model: str = "unknown" + + @abstractmethod + def generate_video(self, *, target_path: Path, prompt: str, context: dict[str, Any]) -> GeneratedArtifact: + """在 ``target_path`` 生成一段视频产物并返回统一结果。""" + raise NotImplementedError + + +class VoiceProvider(ABC): + """语音合成供应商边界。""" + + name: str = "base-voice" + model: str = "unknown" + + @abstractmethod + def generate_voice(self, *, target_path: Path, text: str, context: dict[str, Any]) -> GeneratedArtifact: + """在 ``target_path`` 生成一段语音产物并返回统一结果。""" + raise NotImplementedError + + +class Composer(ABC): + """成片合成边界(后续可接 FFmpeg 等实现)。""" + + name: str = "base-composer" + model: str = "unknown" + + @abstractmethod + def compose(self, *, target_path: Path, shot_inputs: list[dict[str, Any]], context: dict[str, Any]) -> GeneratedArtifact: + """把各镜头产物合成为最终成片并返回统一结果。""" + raise NotImplementedError + + +@dataclass(slots=True) +class ProviderBundle: + """一次生产运行所使用的供应商集合(便于注入与测试)。""" + + image: ImageProvider + video: VideoProvider + voice: VoiceProvider + composer: Composer + + def describe(self) -> dict[str, dict[str, str]]: + """返回用于 manifest 的供应商描述。""" + return { + "image": {"provider": self.image.name, "model": self.image.model}, + "video": {"provider": self.video.name, "model": self.video.model}, + "voice": {"provider": self.voice.name, "model": self.voice.model}, + "composer": {"provider": self.composer.name, "model": self.composer.model}, + } + + +__all__ = ["GeneratedArtifact", "ImageProvider", "VideoProvider", "VoiceProvider", "Composer", "ProviderBundle"] diff --git a/backend/app/crypto_animal_studio/production/providers/mock.py b/backend/app/crypto_animal_studio/production/providers/mock.py new file mode 100644 index 00000000..bd391cec --- /dev/null +++ b/backend/app/crypto_animal_studio/production/providers/mock.py @@ -0,0 +1,120 @@ +"""确定性 Mock 供应商。 + +用途:在不接入任何真实 AI/FFmpeg 服务的前提下,跑通端到端生产流水线。 +Mock 供应商会**真实写文件**(不是仅返回内存成功),且内容确定:相同输入 → 相同字节。 + +测试可通过 ``fail_on_sequence`` 强制某个镜头失败,以验证失败与重试语义。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from app.crypto_animal_studio.production.providers.base import ( + Composer, + GeneratedArtifact, + ImageProvider, + VideoProvider, + VoiceProvider, +) + + +class MockProviderFailure(RuntimeError): + """Mock 供应商的受控失败(用于验证失败/重试路径)。""" + + +def _write(target_path: Path, lines: list[str]) -> None: + """确定性写入文本文件(LF 换行,UTF-8,不含时间戳等易变内容)。""" + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") + + +class MockImageProvider(ImageProvider): + """确定性图像 Mock:写出 image.txt。""" + + name = "mock-image" + model = "mock-image-v0" + + def __init__(self, *, fail_on_sequence: int | None = None) -> None: + """``fail_on_sequence`` 指定时,对该镜头抛出受控失败。""" + self._fail_on_sequence = fail_on_sequence + + def generate_image(self, *, target_path: Path, prompt: str, negative_prompt: str, context: dict[str, Any]) -> GeneratedArtifact: + """生成确定性的图像占位产物。""" + if self._fail_on_sequence is not None and context.get("sequence") == self._fail_on_sequence: + raise MockProviderFailure(f"mock image failure at sequence {self._fail_on_sequence}") + _write(target_path, [f"provider={self.name}", f"model={self.model}", f"shot={context.get('shot_id', '')}", f"prompt={prompt}", f"negative_prompt={negative_prompt}"]) + return GeneratedArtifact(file_path=target_path, mime_type="text/plain", provider=self.name, provider_model=self.model, metadata={"kind": "image"}) + + +class MockVideoProvider(VideoProvider): + """确定性视频 Mock:写出 video.txt。""" + + name = "mock-video" + model = "mock-video-v0" + + def __init__(self, *, fail_on_sequence: int | None = None) -> None: + """``fail_on_sequence`` 指定时,对该镜头抛出受控失败。""" + self._fail_on_sequence = fail_on_sequence + + def generate_video(self, *, target_path: Path, prompt: str, context: dict[str, Any]) -> GeneratedArtifact: + """生成确定性的视频占位产物。""" + if self._fail_on_sequence is not None and context.get("sequence") == self._fail_on_sequence: + raise MockProviderFailure(f"mock video failure at sequence {self._fail_on_sequence}") + _write(target_path, [f"provider={self.name}", f"model={self.model}", f"shot={context.get('shot_id', '')}", f"duration_seconds={context.get('duration_seconds', 0)}", f"prompt={prompt}"]) + return GeneratedArtifact(file_path=target_path, mime_type="text/plain", provider=self.name, provider_model=self.model, metadata={"kind": "video"}) + + +class MockVoiceProvider(VoiceProvider): + """确定性语音 Mock:写出 voice.txt。""" + + name = "mock-voice" + model = "mock-voice-v0" + + def __init__(self, *, fail_on_sequence: int | None = None) -> None: + """``fail_on_sequence`` 指定时,对该镜头抛出受控失败。""" + self._fail_on_sequence = fail_on_sequence + + def generate_voice(self, *, target_path: Path, text: str, context: dict[str, Any]) -> GeneratedArtifact: + """生成确定性的语音占位产物。""" + if self._fail_on_sequence is not None and context.get("sequence") == self._fail_on_sequence: + raise MockProviderFailure(f"mock voice failure at sequence {self._fail_on_sequence}") + _write(target_path, [f"provider={self.name}", f"model={self.model}", f"shot={context.get('shot_id', '')}", "text:", text]) + return GeneratedArtifact(file_path=target_path, mime_type="text/plain", provider=self.name, provider_model=self.model, metadata={"kind": "voice"}) + + +class MockComposer(Composer): + """确定性成片 Mock:写出 final_video.txt。""" + + name = "mock-composer" + model = "mock-composer-v0" + + def __init__(self, *, fail: bool = False) -> None: + """``fail=True`` 时抛出受控失败(用于验证 composition 阶段失败)。""" + self._fail = fail + + def compose(self, *, target_path: Path, shot_inputs: list[dict[str, Any]], context: dict[str, Any]) -> GeneratedArtifact: + """把各镜头产物"合成"为确定性的成片占位文件。""" + if self._fail: + raise MockProviderFailure("mock composer failure") + lines = [f"provider={self.name}", f"model={self.model}", f"episode={context.get('episode_id', '')}", f"shot_count={len(shot_inputs)}"] + for item in shot_inputs: + lines.append(f"shot {item['sequence']}:{item['shot_id']} video={item.get('video', '')} voice={item.get('voice', '')}") + _write(target_path, lines) + return GeneratedArtifact(file_path=target_path, mime_type="text/plain", provider=self.name, provider_model=self.model, metadata={"kind": "final_video"}) + + +def build_mock_bundle(**kwargs: Any): + """构造一套 Mock 供应商集合(默认全部成功)。""" + from app.crypto_animal_studio.production.providers.base import ProviderBundle + + return ProviderBundle( + image=MockImageProvider(fail_on_sequence=kwargs.get("image_fail_on_sequence")), + video=MockVideoProvider(fail_on_sequence=kwargs.get("video_fail_on_sequence")), + voice=MockVoiceProvider(fail_on_sequence=kwargs.get("voice_fail_on_sequence")), + composer=MockComposer(fail=bool(kwargs.get("composer_fail"))), + ) + + +__all__ = ["MockImageProvider", "MockVideoProvider", "MockVoiceProvider", "MockComposer", "MockProviderFailure", "build_mock_bundle"] diff --git a/backend/app/crypto_animal_studio/schemas/episode_package.py b/backend/app/crypto_animal_studio/schemas/episode_package.py index 42263871..e5bf09f5 100644 --- a/backend/app/crypto_animal_studio/schemas/episode_package.py +++ b/backend/app/crypto_animal_studio/schemas/episode_package.py @@ -17,12 +17,13 @@ from __future__ import annotations -from typing import Optional +from typing import ClassVar, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.crypto_animal_studio.domain.episode_package import ( SCHEMA_VERSION, + SCHEMA_VERSION_V1_1, CasCameraAngle, CasCameraMovement, CasShotType, @@ -234,12 +235,17 @@ class EpisodePackage(BaseModel): shots: list[Shot] = Field(..., min_length=1, description="镜头列表(至少一个)") metadata: EpisodeMetadata = Field(..., description="生成元信息") + #: 本模型接受的版本集合。v1 模型只接受 "1.0";v1.1 子类覆盖为 {"1.1"}。 + #: 以显式成员判断实现版本分派,避免一个模型「意外地」同时接受两个版本。 + allowed_schema_versions: ClassVar[frozenset[str]] = frozenset({SCHEMA_VERSION}) + @field_validator("schema_version") @classmethod def _check_schema_version(cls, value: str) -> str: - """规则 1:schema_version 必须等于当前契约版本 "1.0"。""" - if value != SCHEMA_VERSION: - raise ValueError(f'schema_version must equal "{SCHEMA_VERSION}", got "{value}"') + """规则 1:schema_version 必须属于本模型允许的版本集合。""" + if value not in cls.allowed_schema_versions: + expected = " or ".join(f'"{item}"' for item in sorted(cls.allowed_schema_versions)) + raise ValueError(f'schema_version must equal {expected}, got "{value}"') return value @model_validator(mode="after") @@ -264,18 +270,9 @@ def _validate_cross_references(self) -> "EpisodePackage": character_key_set = set(character_keys) # --- 素材键唯一 & 集合 --- - actor_keys = [a.actor_key for a in self.assets.actors] - scene_keys = [s.scene_key for s in self.assets.scenes] - prop_keys = [p.prop_key for p in self.assets.props] - costume_keys = [c.costume_key for c in self.assets.costumes] - _collect_duplicates(actor_keys, "assets.actors[].actor_key", errors) - _collect_duplicates(scene_keys, "assets.scenes[].scene_key", errors) - _collect_duplicates(prop_keys, "assets.props[].prop_key", errors) - _collect_duplicates(costume_keys, "assets.costumes[].costume_key", errors) - actor_key_set = set(actor_keys) - scene_key_set = set(scene_keys) - prop_key_set = set(prop_keys) - costume_key_set = set(costume_keys) + actor_key_set, scene_key_set, prop_key_set, costume_key_set = _collect_asset_key_sets( + self.assets, errors + ) # --- character 对素材的引用 --- for character in self.characters: @@ -332,6 +329,34 @@ def _validate_cross_references(self) -> "EpisodePackage": return self +def _collect_asset_key_sets( + assets: "AssetLibrary", errors: list[str] +) -> tuple[set[str], set[str], set[str], set[str]]: + """辅助:校验四类素材各自的 key 唯一性(规则 17),并返回四个键集合。 + + 抽成模块级函数而非在校验器内内联,是为了让 ``assets`` 拥有显式的参数注解: + astroid/pylint 依据参数注解解析 ``AssetLibrary`` 的成员,而在模型方法内直接访问 + ``self.assets`` 时会把 Pydantic v2 的类属性推断为 ``FieldInfo``(误报 E1101)。 + 运行时行为与内联写法完全等价。 + + 参数: + assets: 待检查的素材库。 + errors: 错误累积列表(就地追加)。 + + 返回: + ``(actor_keys, scene_keys, prop_keys, costume_keys)`` 四个集合。 + """ + actor_keys = [a.actor_key for a in assets.actors] + scene_keys = [s.scene_key for s in assets.scenes] + prop_keys = [p.prop_key for p in assets.props] + costume_keys = [c.costume_key for c in assets.costumes] + _collect_duplicates(actor_keys, "assets.actors[].actor_key", errors) + _collect_duplicates(scene_keys, "assets.scenes[].scene_key", errors) + _collect_duplicates(prop_keys, "assets.props[].prop_key", errors) + _collect_duplicates(costume_keys, "assets.costumes[].costume_key", errors) + return set(actor_keys), set(scene_keys), set(prop_keys), set(costume_keys) + + def _collect_duplicates(values: list, where: str, errors: list[str]) -> None: """辅助:把 ``values`` 中的重复项以可读信息追加到 ``errors``。 @@ -349,3 +374,277 @@ def _collect_duplicates(values: list, where: str, errors: list[str]) -> None: if dups: rendered = ", ".join(str(d) for d in sorted(dups, key=str)) errors.append(f"{where} contains duplicate values: {rendered}") + + +# --------------------------------------------------------------------------- # +# EpisodePackage v1.1 —— 附加式扩展(全部可选) +# +# 规范:docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md +# 决策:docs/adr/ADR-016-episode-package-v1-1.md(Status: Proposed) +# +# 纪律: +# - v1 模型与字段一律不改名、不改类型、不改语义; +# - 新增字段全部可选,因此 "1.0" 文档在 v1.1 解析器下依然有效; +# - 不新增第二个连续性字段(沿用 ``shots[].continuity_notes``); +# - 不新增第二个运镜字段(沿用 ``shots[].camera.movement``); +# - 不引入镜头相对时间(overlay/cue 一律 episode-absolute 毫秒); +# - 不引入任何供应商执行字段。 +# --------------------------------------------------------------------------- # +class SafeArea(BaseModel): + """安全区元数据(百分比)。""" + + model_config = ConfigDict(extra="forbid") + + subtitle_bottom_pct: float = Field(18, ge=0, le=50, description="字幕安全带(画面底部百分比)") + margin_pct: float = Field(6, ge=0, le=25, description="通用安全边距(百分比)") + + +class OutputSpec(BaseModel): + """输出规格;``*_ms`` 断言永不覆盖派生时长。""" + + model_config = ConfigDict(extra="forbid") + + aspect_ratio: str = Field("9:16", description="画面比例,形如 W:H") + width: int = Field(1080, gt=0, description="渲染宽度(像素)") + height: int = Field(1920, gt=0, description="渲染高度(像素)") + fps: int = Field(30, gt=0, description="帧率") + orientation: Literal["vertical", "horizontal", "square"] = Field("vertical", description="画面方向") + generated_footage_ms: Optional[int] = Field(None, ge=0, description="生成footage总毫秒(可选断言)") + total_runtime_ms: Optional[int] = Field(None, gt=0, description="最终成片总毫秒(可选断言)") + safe_area: SafeArea = Field(default_factory=SafeArea, description="安全区元数据") + + +class SubtitleCue(BaseModel): + """字幕单条 cue;时间为 episode-absolute 整数毫秒。""" + + model_config = ConfigDict(extra="forbid") + + cue_id: str = Field(..., min_length=1, description="cue 稳定 ID(轨内唯一)") + start_ms: int = Field(..., ge=0, description="入点(episode-absolute 毫秒)") + end_ms: int = Field(..., gt=0, description="出点(必须大于 start_ms)") + text: str = Field(..., min_length=1, description="译文(非空)") + speaker_character_key: Optional[str] = Field(None, description="说话角色键(须存在于 characters)") + shot_id: Optional[str] = Field(None, description="关联镜头(仅关联,不构成第二套时间真相)") + + @model_validator(mode="after") + def _check_span(self) -> "SubtitleCue": + """cue 时长必须为正(禁止零长度/负长度)。""" + if self.end_ms <= self.start_ms: + raise ValueError(f"cue '{self.cue_id}': end_ms must be greater than start_ms") + return self + + +class SubtitleTrack(BaseModel): + """一条字幕轨。渲染默认属于后期,不进入 AI 生成。""" + + model_config = ConfigDict(extra="forbid") + + language_tag: str = Field(..., min_length=1, description="BCP 47 语言标签,如 zh-Hant") + is_primary: bool = Field(False, description="是否为主轨") + rendering: Literal["post_production", "burned_in", "sidecar"] = Field( + "post_production", description="渲染方式(声明性;默认后期)" + ) + cues: list[SubtitleCue] = Field(..., description="cue 列表(可为空,但后期阶段起视为无效)") + + +class Localization(BaseModel): + """口语与字幕本地化。字幕结构上可选;必需语言只来自 required_publish_language_tags。""" + + model_config = ConfigDict(extra="forbid") + + spoken_language: Optional[str] = Field(None, description="对白语言(缺省回落到根 language)") + required_publish_language_tags: list[str] = Field( + default_factory=list, description="发布前必须具备字幕的语言标签;空表示无要求" + ) + subtitle_tracks: list[SubtitleTrack] = Field(default_factory=list, description="字幕轨列表") + + +class FactCardLocalizedCopy(BaseModel): + """fact card 的单语言文案。""" + + model_config = ConfigDict(extra="forbid") + + language_tag: str = Field(..., min_length=1, description="BCP 47 语言标签") + body: list[str] = Field(..., description="教育性正文行(每行非空)") + disclaimer: str = Field(..., min_length=1, description="免责声明(非空)") + cta: Optional[str] = Field(None, description="可选 CTA") + + @model_validator(mode="after") + def _check_body(self) -> "FactCardLocalizedCopy": + """正文行必须存在且非空白。""" + if not self.body or any(not line.strip() for line in self.body): + raise ValueError(f"fact_card localized '{self.language_tag}': body lines must be non-empty") + return self + + +class FactCard(BaseModel): + """后期 fact card;**永远不是第五个生成镜头**。""" + + model_config = ConfigDict(extra="forbid") + + duration_ms: int = Field(..., gt=0, description="卡片时长(毫秒)") + placement: Literal["append_after_shots", "overlay_tail"] = Field( + "append_after_shots", description="追加式才计入总时长" + ) + readable_text_in_post: Literal[True] = Field(True, description="卡面文字一律后期合成") + localized: list[FactCardLocalizedCopy] = Field(..., min_length=1, description="各语言文案") + + +class DataLock(BaseModel): + """市场数据锁定状态。""" + + model_config = ConfigDict(extra="forbid") + + status: Literal["unresolved", "locked"] = Field("unresolved", description="锁定状态") + locked_at_utc: Optional[str] = Field(None, description="锁定时间(ISO-8601)") + + +class MarketData(BaseModel): + """市场事实溯源。数值刻意为「可含占位符的字符串」(最小化 v1.1 折衷)。""" + + model_config = ConfigDict(extra="forbid") + + instrument: str = Field(..., min_length=1, description="标的,如 BTC-USD") + timeframe: str = Field(..., min_length=1, description="确认所用周期,如 4h") + resistance_level: Optional[str] = Field(None, description="被突破的阻力位") + price: Optional[str] = Field(None, description="事件时价格") + price_move_pct: Optional[str] = Field(None, description="区间涨跌幅") + pullback_pct: Optional[str] = Field(None, description="回撤幅度") + event_timestamp_utc: Optional[str] = Field(None, description="事件时间") + candle_close_timestamp_utc: Optional[str] = Field(None, description="确认K棒收盘时间") + as_of_utc: Optional[str] = Field(None, description="数据 as-of 时间") + source_name: Optional[str] = Field(None, description="数据来源名称") + source_url: Optional[str] = Field(None, description="公开溯源 URL(仅证据,非执行端点)") + factual_note: Optional[str] = Field(None, description="人工核对备注") + ath_context: Optional[str] = Field(None, description="可选前高背景") + data_lock: DataLock = Field(default_factory=DataLock, description="锁定状态") + + +class ReferenceAsset(BaseModel): + """一条参考资产:稳定 asset_id + 可选仓库相对路径(禁止供应商 URL)。""" + + model_config = ConfigDict(extra="forbid") + + character_key: Optional[str] = Field(None, description="角色键(角色参考用)") + scene_key: Optional[str] = Field(None, description="场景键(环境参考用)") + prop_key: Optional[str] = Field(None, description="道具键(道具参考用)") + asset_id: str = Field(..., min_length=1, description="稳定不透明资产 ID") + kind: Literal["identity", "episode"] = Field("identity", description="不可变身份参考 vs 本集专用") + view: Optional[str] = Field(None, description="视角提示,如 front") + path: Optional[str] = Field(None, description="仓库相对路径;**不得**为供应商 URL") + + +class References(BaseModel): + """Bible 版本与参考资产集合。""" + + model_config = ConfigDict(extra="forbid") + + bible_version: Optional[str] = Field(None, description="Bible 版本,如 1.0") + canon_decision: Optional[str] = Field(None, description="治理决策,如 ADR-015") + characters: list[ReferenceAsset] = Field(default_factory=list, description="角色参考") + environments: list[ReferenceAsset] = Field(default_factory=list, description="环境参考") + props: list[ReferenceAsset] = Field(default_factory=list, description="道具参考") + + +class OverlayLocalizedText(BaseModel): + """叠加图形的单语言文案。""" + + model_config = ConfigDict(extra="forbid") + + language_tag: str = Field(..., min_length=1, description="BCP 47 语言标签") + text: str = Field(..., description="文案") + + +class PostProductionOverlay(BaseModel): + """后期叠加图形;时间为 episode-absolute 毫秒,shot_id 仅作关联。""" + + model_config = ConfigDict(extra="forbid") + + overlay_id: str = Field(..., min_length=1, description="稳定 ID(被 shots[].overlay_ids 引用)") + type: Literal["chart_label", "subtitle", "notification", "fact_card", "disclaimer", "cta", "other"] = Field( + ..., description="叠加类型" + ) + shot_id: Optional[str] = Field(None, description="关联镜头(null 表示 episode 级)") + start_ms: Optional[int] = Field(None, ge=0, description="入点(episode-absolute 毫秒)") + end_ms: Optional[int] = Field(None, gt=0, description="出点(episode-absolute 毫秒)") + required: bool = Field(True, description="是否必需(可选叠加允许省略)") + anchor: Literal["lower_safe", "upper_safe", "centre", "prop_local"] = Field( + "lower_safe", description="安全区锚点" + ) + localized: list[OverlayLocalizedText] = Field(default_factory=list, description="各语言文案") + + @model_validator(mode="after") + def _check_span(self) -> "PostProductionOverlay": + """两端同时给出时,出点必须大于入点。""" + if self.start_ms is not None and self.end_ms is not None and self.end_ms <= self.start_ms: + raise ValueError(f"overlay '{self.overlay_id}': end_ms must be greater than start_ms") + return self + + +class PostProduction(BaseModel): + """后期叠加计划:所有可读金融文字都在这里,不进入生成画面。""" + + model_config = ConfigDict(extra="forbid") + + overlays: list[PostProductionOverlay] = Field(default_factory=list, description="叠加列表") + + +class RegenerationFallback(BaseModel): + """重生成兜底(仅恢复手段,不是同等生产选项)。""" + + model_config = ConfigDict(extra="forbid") + + camera_movement: Optional[CasCameraMovement] = Field(None, description="兜底运镜(复用既有枚举)") + note: str = Field("", description="适用条件说明") + + +class ShotV11(Shot): + """v1.1 镜头:在 v1 ``Shot`` 之上仅新增五个可选字段。 + + 刻意不新增:连续性字段(用 ``continuity_notes``)、运镜字段(用 ``camera.movement``)、 + 任何镜头相对时间字段。 + """ + + beginning_state: str = Field("", description="起始状态(生成用)") + ending_state: str = Field("", description="结束状态(生成用)") + generation_risks: list[str] = Field(default_factory=list, description="已知生成风险") + regeneration_fallback: Optional[RegenerationFallback] = Field(None, description="仅恢复用兜底方案") + overlay_ids: list[str] = Field(default_factory=list, description="关联的后期叠加 ID") + + +class EpisodePackageV11(EpisodePackage): + """EpisodePackage v1.1 根对象:v1 全部字段 + 六个可选顶层对象;shots 使用 ShotV11。""" + + allowed_schema_versions: ClassVar[frozenset[str]] = frozenset({SCHEMA_VERSION_V1_1}) + + shots: list[ShotV11] = Field(..., min_length=1, description="镜头列表(至少一个)") + + output: Optional[OutputSpec] = Field(None, description="输出规格(缺省时用文档化默认值)") + localization: Optional[Localization] = Field(None, description="口语与字幕") + fact_card: Optional[FactCard] = Field(None, description="后期 fact card") + market_data: Optional[MarketData] = Field(None, description="市场事实溯源") + references: Optional[References] = Field(None, description="Bible 与参考资产") + post_production: Optional[PostProduction] = Field(None, description="后期叠加计划") + + +#: 缺省 output(仅用于派生视图,绝不写回源文档)。 +DEFAULT_OUTPUT_SPEC = OutputSpec() + + +# --------------------------------------------------------------------------- # +# 版本联合类型(供 API 请求模型复用) +# +# 用法:``episode_package: AnyEpisodePackage = Field(..., union_mode="left_to_right")`` +# +# 为什么用 left_to_right: +# - 先尝试 ``EpisodePackageV11``(只接受 "1.1"),再回落到 ``EpisodePackage``(只接受 "1.0"); +# - 版本选择依然由各模型的 ``allowed_schema_versions`` 单一真相决定,不重复实现分派逻辑; +# - 默认的 smart union 会因为 V11 是 EpisodePackage 的子类而可能"降级"匹配到父类 +# (静默丢弃 v1.1 字段),left_to_right 明确避免这一点; +# - 缺失 ``schema_version`` 仍产生既有的 ``missing`` 错误;未知版本产生 422 校验错误; +# - 不改写、不升级、不修改任何 payload。 +# --------------------------------------------------------------------------- # +AnyEpisodePackage = Union[EpisodePackageV11, EpisodePackage] + +#: 请求模型声明该字段时应使用的 union 模式。 +EPISODE_PACKAGE_UNION_MODE = "left_to_right" diff --git a/backend/app/crypto_animal_studio/schemas/import_request.py b/backend/app/crypto_animal_studio/schemas/import_request.py index 34f8faba..09944f84 100644 --- a/backend/app/crypto_animal_studio/schemas/import_request.py +++ b/backend/app/crypto_animal_studio/schemas/import_request.py @@ -4,7 +4,10 @@ from pydantic import BaseModel, ConfigDict, Field -from app.crypto_animal_studio.schemas.episode_package import EpisodePackage +from app.crypto_animal_studio.schemas.episode_package import ( + EPISODE_PACKAGE_UNION_MODE, + AnyEpisodePackage, +) class ImportEpisodeRequest(BaseModel): @@ -13,9 +16,26 @@ class ImportEpisodeRequest(BaseModel): model_config = ConfigDict(extra="forbid") project_id: str = Field(..., min_length=1, description="目标 Jellyfish 项目 ID(系列/季)") - episode_package: EpisodePackage = Field(..., description="待导入的 EpisodePackage(严格校验)") + episode_package: AnyEpisodePackage = Field( + ..., + union_mode=EPISODE_PACKAGE_UNION_MODE, + description="待导入的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1)", + ) dry_run: bool = Field(False, description="为真时只校验/映射/复用查找/告警,不写库") idempotency_key: str = Field(..., min_length=1, description="幂等键") -__all__ = ["ImportEpisodeRequest"] +class CasImportTaskAccepted(BaseModel): + """POST /import/async 的响应体:任务已受理。""" + + model_config = ConfigDict(extra="forbid") + + task_id: str = Field(..., description="任务中心任务 ID") + status: str = Field(..., description="任务状态(pending/running/...)") + reused: bool = Field(..., description="是否复用了同一剧集的活动任务") + task_kind: str = Field(..., description="任务种类(cas_import_episode_package)") + relation_type: str = Field(..., description="业务关联类型") + relation_entity_id: str = Field(..., description="业务关联实体键(project+episode 摘要)") + + +__all__ = ["ImportEpisodeRequest", "CasImportTaskAccepted"] diff --git a/backend/app/crypto_animal_studio/schemas/production.py b/backend/app/crypto_animal_studio/schemas/production.py new file mode 100644 index 00000000..a4c62170 --- /dev/null +++ b/backend/app/crypto_animal_studio/schemas/production.py @@ -0,0 +1,99 @@ +"""生产 API 的请求/响应模型。""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from app.crypto_animal_studio.schemas.episode_package import ( + EPISODE_PACKAGE_UNION_MODE, + AnyEpisodePackage, +) + + +class CreateProductionJobRequest(BaseModel): + """POST /production/jobs 请求体。""" + + model_config = ConfigDict(extra="forbid") + + project_id: str = Field(..., min_length=1, description="项目 ID") + episode_package: AnyEpisodePackage = Field( + ..., + union_mode=EPISODE_PACKAGE_UNION_MODE, + description="待生产的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1)", + ) + mode: Literal["mock"] = Field("mock", description="供应商模式;本冲刺仅支持 mock") + + +class RetryProductionJobRequest(BaseModel): + """POST /production/jobs/{job_id}/retry 请求体。""" + + model_config = ConfigDict(extra="forbid") + + episode_package: AnyEpisodePackage = Field( + ..., + union_mode=EPISODE_PACKAGE_UNION_MODE, + description="与原任务一致的 EpisodePackage(用于重跑;接受 schema_version 1.0 或 1.1)", + ) + mode: Literal["mock"] = Field("mock", description="供应商模式;本冲刺仅支持 mock") + + +class ProductionShotView(BaseModel): + """生产镜头视图。""" + + model_config = ConfigDict(extra="forbid") + + id: str + source_shot_id: str + sequence: int + status: str + current_stage: str + duration_seconds: float + error_message: str + + +class ProductionArtifactView(BaseModel): + """产物视图。""" + + model_config = ConfigDict(extra="forbid") + + id: str + production_shot_id: str | None + artifact_type: str + stage: str + provider: str + provider_model: str + file_path: str + mime_type: str + checksum: str + + +class ProductionJobView(BaseModel): + """生产任务视图。""" + + model_config = ConfigDict(extra="forbid") + + id: str + project_id: str + episode_id: str + status: str + current_stage: str + provider_mode: str + episode_package_hash: str + output_path: str + error_message: str + started_at: str | None = None + completed_at: str | None = None + shots: list[ProductionShotView] = Field(default_factory=list) + manifest_path: str | None = None + final_output: str | None = None + + +__all__ = [ + "CreateProductionJobRequest", + "RetryProductionJobRequest", + "ProductionJobView", + "ProductionShotView", + "ProductionArtifactView", +] diff --git a/backend/app/models/types.py b/backend/app/models/types.py index 77cf2d8b..e773d093 100644 --- a/backend/app/models/types.py +++ b/backend/app/models/types.py @@ -137,6 +137,7 @@ class FileType(str, Enum): image = "image" video = "video" + subtitle = "subtitle" # WebVTT 等字幕文本产物(files.type 为 String(16),新增取值无需迁移) class FileUsageKind(str, Enum): @@ -149,6 +150,7 @@ class FileUsageKind(str, Enum): task_link = "task_link" upload = "upload" api = "api" + subtitle = "subtitle" # 字幕产物(如 CAS 导入生成的 WebVTT 轨) class TimelineClipType(str, Enum): diff --git a/backend/app/schemas/studio/files.py b/backend/app/schemas/studio/files.py index 052a1c62..a11be0ce 100644 --- a/backend/app/schemas/studio/files.py +++ b/backend/app/schemas/studio/files.py @@ -10,6 +10,7 @@ class FileTypeEnum(str, Enum): image = "image" video = "video" + subtitle = "subtitle" # 与 models.types.FileType 对齐;WebVTT 等字幕文本产物 class FileBase(BaseModel): diff --git a/backend/app/services/film/generated_video.py b/backend/app/services/film/generated_video.py index 481a648e..3055d4c1 100644 --- a/backend/app/services/film/generated_video.py +++ b/backend/app/services/film/generated_video.py @@ -81,7 +81,8 @@ async def preview_prompt_and_images( prompt: str | None, images: list[str] | None = None, ) -> tuple[str, list[str], dict | None]: - shot_detail = await validate_shot_and_duration(db, shot_id) + # 仅用于校验镜头与时长(副作用);返回值在本函数中不需要。 + await validate_shot_and_duration(db, shot_id) base = build_video_base_draft(shot_id=shot_id, prompt=prompt) context = await build_video_context( db, diff --git a/backend/app/services/studio/action_beats.py b/backend/app/services/studio/action_beats.py index 05413cbb..7ee5f52c 100644 --- a/backend/app/services/studio/action_beats.py +++ b/backend/app/services/studio/action_beats.py @@ -82,6 +82,20 @@ def _count_hits(text: str, keywords: tuple[str, ...]) -> int: return sum(1 for keyword in keywords if keyword in text) +def _phase_from_position(index: int, total: int) -> ActionBeatPhase: + """位置兜底(无关键词命中时使用)。 + + - 首条 -> trigger + - 总数 >= 3 时的尾条 -> aftermath + - 其余 -> peak + """ + if index == 0: + return "trigger" + if total >= 3 and index == total - 1: + return "aftermath" + return "peak" + + def infer_action_beat_phase(*, text: str, index: int, total: int) -> ActionBeatPhase: """为单条动作拍点推断阶段。 @@ -110,11 +124,7 @@ def infer_action_beat_phase(*, text: str, index: int, total: int) -> ActionBeatP if peak_hits > 0: return "peak" - if index == 0: - return "trigger" - if total >= 3 and index == total - 1: - return "aftermath" - return "peak" + return _phase_from_position(index, total) def infer_action_beat_sequence(action_beats: list[str] | None) -> list[ActionBeatPhaseItem]: diff --git a/backend/app/services/studio/file_usages.py b/backend/app/services/studio/file_usages.py index 05dcf507..f50c33fb 100644 --- a/backend/app/services/studio/file_usages.py +++ b/backend/app/services/studio/file_usages.py @@ -124,10 +124,22 @@ def _scope_filters( project_id: str, chapter_title: str | None, shot_title: str | None, + chapter_id: str | None = None, + usage_kind: str | None = None, ) -> list: - """返回与 FileUsage 行组合的 WHERE 条件(不含 project_id 基条件)。""" + """返回与 FileUsage 行组合的 WHERE 条件(不含 project_id 基条件)。 + + ``chapter_id`` / ``usage_kind`` 为附加的精确过滤:按 ID 定位章节比按标题稳妥 + (标题不唯一),``usage_kind`` 用于直接取出某一类产物(如 subtitle)。 + 两者都可选,省略时行为与既有实现完全一致。 + """ conds: list = [] + if chapter_id: + conds.append(FileUsage.chapter_id == chapter_id) + if usage_kind: + conds.append(FileUsage.usage_kind == usage_kind) + ch_title = normalize_q(chapter_title) sh_title = normalize_q(shot_title) @@ -171,6 +183,8 @@ async def list_files_by_scope_paginated( project_id: str, chapter_title: str | None = None, shot_title: str | None = None, + chapter_id: str | None = None, + usage_kind: str | None = None, q: str | None = None, order: str | None = None, is_desc: bool = False, @@ -190,6 +204,8 @@ def _apply_scope_filters(stmt: Select) -> Select: project_id=project_id, chapter_title=chapter_title, shot_title=shot_title, + chapter_id=chapter_id, + usage_kind=usage_kind, ): stmt = stmt.where(c) return stmt diff --git a/backend/app/services/studio/generation/frame/derive_preview.py b/backend/app/services/studio/generation/frame/derive_preview.py index 986832e8..f22cc1d8 100644 --- a/backend/app/services/studio/generation/frame/derive_preview.py +++ b/backend/app/services/studio/generation/frame/derive_preview.py @@ -72,6 +72,12 @@ def _build_frame_guidance_reason( selected: bool, ) -> str: """生成 guidance 被保留或压缩的可解释原因。""" + # 本函数是纯字符串查表:19 个 return 全部为 (selected, category, frame_type) 组合的 + # 解释文案,且**依赖 guard clause 的先后顺序**(例如 category == "frame" 但 frame_type + # 不在 {first,key,last} 时会继续向下匹配)。改写成映射表会破坏这种 fallthrough 语义, + # 或需要额外的优先级列表才能等价,可读性反而下降。故按仓库既有的行级注解方式 + # 窄范围豁免该单条检查(非模块级 disable)。 + # pylint: disable=too-many-return-statements if selected: if category == "frame": if frame_type == "first": @@ -122,6 +128,8 @@ def _build_frame_guidance_reason_tag( selected: bool, ) -> str: """生成更适合前端快速阅读的短标签。""" + # 同上:纯查表 + 依赖 guard clause 顺序的短标签生成,窄范围豁免该单条检查。 + # pylint: disable=too-many-return-statements if category == "summary": return "导演主指令" if category == "frame": diff --git a/backend/app/services/worker/task_registry.py b/backend/app/services/worker/task_registry.py index ba7035c4..525f40b0 100644 --- a/backend/app/services/worker/task_registry.py +++ b/backend/app/services/worker/task_registry.py @@ -8,6 +8,10 @@ from __future__ import annotations +from app.crypto_animal_studio.application.import_tasks import ( + CAS_IMPORT_EPISODE_TASK_KIND, + run_cas_import_task, +) from app.services.film.generated_video import run_video_generation_task from app.services.film.shot_frame_prompt_tasks import run_shot_frame_prompt_task from app.services.script_processing_worker import ( @@ -74,3 +78,12 @@ def resolve(self, task_kind: str) -> AbstractWorkerTaskExecutor: timeout_seconds=600.0, ), ) +# CAS:EpisodePackage 导入。纯数据库+对象存储写入,无模型推理,超时取较短值。 +task_executor_registry.register( + CAS_IMPORT_EPISODE_TASK_KIND, + AbstractAsyncDelegatingExecutor( + task_kind=CAS_IMPORT_EPISODE_TASK_KIND, + runner=run_cas_import_task, + timeout_seconds=300.0, + ), +) diff --git a/backend/sql/010-add-cas-production-tables.sql b/backend/sql/010-add-cas-production-tables.sql new file mode 100644 index 00000000..b6a53d23 --- /dev/null +++ b/backend/sql/010-add-cas-production-tables.sql @@ -0,0 +1,71 @@ +-- 010-add-cas-production-tables.sql +-- CAS 生产流水线表:任务 / 生产镜头 / 产物。 +-- 说明:仅记录**生产运行状态与产物**,不复制 Jellyfish 的 Project/Chapter/Shot/Asset 等创作实体。 +-- project_id / episode_id / source_shot_id 为弱引用(不建外键),以避免与创作域耦合并支持 +-- 尚未导入到 Chapter 的独立生产运行。 + +CREATE TABLE IF NOT EXISTS `cas_production_jobs` ( + `id` VARCHAR(64) NOT NULL COMMENT '任务 ID(UUID)', + `project_id` VARCHAR(64) NOT NULL COMMENT '项目 ID(弱引用)', + `episode_id` VARCHAR(255) NOT NULL COMMENT 'Episode ID(弱引用)', + `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/completed/failed/cancelled', + `current_stage` VARCHAR(32) NOT NULL DEFAULT 'validate' COMMENT '当前阶段', + `episode_package_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'EpisodePackage 规范化 SHA-256', + `provider_mode` VARCHAR(32) NOT NULL DEFAULT 'mock' COMMENT '供应商模式(本冲刺仅 mock)', + `started_at` DATETIME NULL COMMENT '开始时间', + `completed_at` DATETIME NULL COMMENT '完成时间', + `error_message` TEXT NOT NULL COMMENT '错误信息', + `output_path` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '任务输出根目录(相对存储根)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_cas_prod_jobs_project_id` (`project_id`), + KEY `ix_cas_prod_jobs_episode_id` (`episode_id`), + KEY `ix_cas_prod_jobs_project_episode` (`project_id`, `episode_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CAS 生产任务'; + +CREATE TABLE IF NOT EXISTS `cas_production_shots` ( + `id` VARCHAR(64) NOT NULL COMMENT '生产镜头 ID(UUID)', + `job_id` VARCHAR(64) NOT NULL COMMENT '所属任务 ID', + `source_shot_id` VARCHAR(255) NOT NULL COMMENT 'EpisodePackage 中的 shot_id(弱引用)', + `sequence` INT NOT NULL COMMENT '镜头顺序', + `status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '镜头生产状态', + `current_stage` VARCHAR(32) NOT NULL DEFAULT 'validate' COMMENT '当前阶段', + `image_prompt` TEXT NOT NULL COMMENT '图像提示词', + `negative_prompt` TEXT NOT NULL COMMENT '反向提示词', + `video_prompt` TEXT NOT NULL COMMENT '视频提示词', + `duration_seconds` DOUBLE NOT NULL DEFAULT 0 COMMENT '镜头时长(秒)', + `error_message` TEXT NOT NULL COMMENT '错误信息', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_cas_prod_shots_job_id` (`job_id`), + KEY `ix_cas_prod_shots_job_sequence` (`job_id`, `sequence`), + CONSTRAINT `fk_cas_prod_shots_job` + FOREIGN KEY (`job_id`) REFERENCES `cas_production_jobs` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CAS 生产镜头(仅生产状态)'; + +CREATE TABLE IF NOT EXISTS `cas_production_artifacts` ( + `id` VARCHAR(64) NOT NULL COMMENT '产物 ID(UUID)', + `job_id` VARCHAR(64) NOT NULL COMMENT '所属任务 ID', + `production_shot_id` VARCHAR(64) NULL COMMENT '所属生产镜头 ID(可空:任务级产物)', + `artifact_type` VARCHAR(32) NOT NULL COMMENT '产物类型:prompt/image/video/voice/subtitle/music/manifest/final_video/log', + `stage` VARCHAR(32) NOT NULL COMMENT '产生该产物的阶段', + `provider` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '供应商标识', + `provider_model` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '供应商模型标识', + `file_path` VARCHAR(1024) NOT NULL COMMENT '产物文件路径(相对存储根)', + `mime_type` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MIME 类型', + `checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '文件 SHA-256', + `metadata_json` JSON NOT NULL COMMENT '附加元信息', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `ix_cas_prod_artifacts_job_id` (`job_id`), + KEY `ix_cas_prod_artifacts_shot_id` (`production_shot_id`), + KEY `ix_cas_prod_artifacts_type` (`artifact_type`), + KEY `ix_cas_prod_artifacts_job_type` (`job_id`, `artifact_type`), + CONSTRAINT `fk_cas_prod_artifacts_job` + FOREIGN KEY (`job_id`) REFERENCES `cas_production_jobs` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_cas_prod_artifacts_shot` + FOREIGN KEY (`production_shot_id`) REFERENCES `cas_production_shots` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CAS 生产产物(Artifact First)'; diff --git a/backend/tests/core/integrations/test_video_capabilities.py b/backend/tests/core/integrations/test_video_capabilities.py index 3aef227a..8fb4f1ae 100644 --- a/backend/tests/core/integrations/test_video_capabilities.py +++ b/backend/tests/core/integrations/test_video_capabilities.py @@ -50,9 +50,46 @@ def test_validate_video_options_rejects_capability_mismatch() -> None: capability=VideoModelCapability(supports_seed=False), ) try: - inp = VideoGenerationInput(prompt="test", model="seedream-video-v1", seed=7) + inp = VideoGenerationInput(prompt="test", model="seedream-video-v1", ratio="16:9", seed=7) with pytest.raises(ValueError) as exc_info: validate_video_options(provider="volcengine", model=inp.model, input_=inp) assert "seed is not supported" in str(exc_info.value) finally: clear_video_model_capability_overrides(provider="volcengine") + + +@pytest.mark.parametrize( + "value", + [ + "", + " ", + "1920x", + "x1080", + "19.2x10.8", + "0x100", + "100x0", + "-16x9", + "1000x37", + "5:3", + None, + ], +) +def test_infer_ratio_from_size_rejects_invalid_input(value: str | None) -> None: + """空白、格式非法、零/负尺寸与不受支持的比例一律返回 None。""" + assert infer_ratio_from_size(value) is None + + +@pytest.mark.parametrize( + "value,expected", + [ + (" 16:9 ", "16:9"), + ("1920X1080", "16:9"), + ("1920 x 1080", "16:9"), + ("1024x1024", "1:1"), + ("768x1024", "3:4"), + ("1024x768", "4:3"), + ], +) +def test_infer_ratio_from_size_tolerates_whitespace_and_case(value: str, expected: str) -> None: + """容忍首尾空白、内部空格与大写 X;约简后须落在 ALLOWED_RATIOS 内。""" + assert infer_ratio_from_size(value) == expected diff --git a/backend/tests/fixtures/cas/ep001_shaped_v11.json b/backend/tests/fixtures/cas/ep001_shaped_v11.json new file mode 100644 index 00000000..16fde445 --- /dev/null +++ b/backend/tests/fixtures/cas/ep001_shaped_v11.json @@ -0,0 +1,281 @@ +{ + "schema_version": "1.1", + "episode_id": "CAS-EP001-FIXTURE", + "title": "BTC Breaks Out — Bruno Celebrates Too Early (test fixture)", + "logline": "Bitcoin pushes above resistance and Bruno throws a party on the signal alone.", + "language": "en", + "source": { + "source_type": "news", + "headline": "BTC moves above a prior resistance area", + "summary": "Price moved above a prior resistance area; confirmation still outstanding.", + "source_url": "https://example-exchange.test/markets/btc-usd", + "published_at": "2026-01-02T09:00:00Z", + "factual_notes": "Initial breakout signal only; not a confirmed breakout." + }, + "creative_direction": { + "format": "short_form_vertical", + "tone": "deadpan", + "target_duration_seconds": 24, + "visual_style": "premium stylized 3D", + "comedy_style": "personality collision", + "continuity_notes": "Bible v1 locked identities; The Burrow layout fixed." + }, + "characters": [ + { + "character_key": "bruno_bull", + "display_name": "Bruno Bull", + "role": "momentum trader", + "description": "Anthropomorphic bull; chestnut-brown fur; forest-green rolled-sleeve shirt; mustard tie; black smartwatch on left wrist.", + "actor_key": "actor_bruno", + "costume_key": "costume_bruno_office", + "voice_profile": "warm baritone, energetic", + "continuity_notes": "Tallest of the trio. No jacket, hat, or glasses." + }, + { + "character_key": "boris_bear", + "display_name": "Boris Bear", + "role": "risk manager", + "description": "Anthropomorphic bear; charcoal-brown fur; burgundy knit vest over pale blue shirt; rectangular black reading glasses.", + "actor_key": "actor_boris", + "costume_key": "costume_boris_office", + "voice_profile": "low, controlled", + "continuity_notes": "Wider than Milo; red notebook and red pen." + }, + { + "character_key": "milo_cat", + "display_name": "Milo Cat", + "role": "strategist", + "description": "Anthropomorphic burnt-orange tabby; three forehead stripes; dark teal turtleneck; silver Bitcoin pin on left chest.", + "actor_key": "actor_milo", + "costume_key": "costume_milo_office", + "voice_profile": "smooth, understated", + "continuity_notes": "Shortest of the trio. Matte black mug." + } + ], + "assets": { + "actors": [ + { "actor_key": "actor_bruno", "display_name": "Bruno", "description": "Bull identity plate." }, + { "actor_key": "actor_boris", "display_name": "Boris", "description": "Bear identity plate." }, + { "actor_key": "actor_milo", "display_name": "Milo", "description": "Cat identity plate." } + ], + "scenes": [ + { "scene_key": "the_burrow", "display_name": "The Burrow", "description": "Trading studio: curved desk, wall BTC chart, coffee station, glass wall." } + ], + "props": [ + { "prop_key": "wall_btc_chart", "display_name": "Wall BTC chart", "description": "Abstract, textless chart plate." }, + { "prop_key": "milo_phone", "display_name": "Phone", "description": "Supporting screen prop; glow only, no legible text." } + ], + "costumes": [ + { "costume_key": "costume_bruno_office", "display_name": "Bruno office", "description": "Forest-green shirt, mustard tie." }, + { "costume_key": "costume_boris_office", "display_name": "Boris office", "description": "Burgundy vest, pale blue shirt." }, + { "costume_key": "costume_milo_office", "display_name": "Milo office", "description": "Dark teal turtleneck." } + ] + }, + "shots": [ + { + "shot_id": "SC01", + "sequence": 1, + "title": "The premature toast", + "duration_seconds": 3.0, + "script_excerpt": "Bruno bursts in as the alert flares.", + "camera": { "shot_type": "MS", "angle": "EYE_LEVEL", "movement": "DOLLY_IN" }, + "action": "Bruno shoulders through the doorway, arms rising.", + "dialogue": [ + { "order": 1, "character_key": "bruno_bull", "text": "Breakout! We are so back!", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bruno_bull", "boris_bear"], + "scene_key": "the_burrow", + "prop_keys": ["wall_btc_chart"], + "costume_keys": ["costume_bruno_office"], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Forest-green shirt; watch on left wrist; symmetrical horns.", + "metadata": { "beat": "hook" }, + "beginning_state": "Door half-open; chart line crossing the level.", + "ending_state": "Bruno fully in frame, arms up; green accent lit.", + "generation_risks": ["extra or asymmetric horns", "jacket appearing", "legible chart text"], + "regeneration_fallback": null, + "overlay_ids": ["ov_chart_label_01"] + }, + { + "shot_id": "SC02", + "sequence": 2, + "title": "Confirmation, please", + "duration_seconds": 7.0, + "script_excerpt": "Boris blocks the celebration.", + "camera": { "shot_type": "MCU", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Boris raises a flat paw, clutching the red notebook.", + "dialogue": [ + { "order": 1, "character_key": "boris_bear", "text": "The candle hasn't closed yet.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["boris_bear", "bruno_bull"], + "scene_key": "the_burrow", + "prop_keys": [], + "costume_keys": ["costume_boris_office"], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Glasses present; vest burgundy; small rounded ears.", + "metadata": { "beat": "conflict" }, + "beginning_state": "Boris mid-turn from his monitor.", + "ending_state": "Paw up, notebook chest-high.", + "generation_risks": ["glasses disappearing", "vest colour drift"], + "regeneration_fallback": null, + "overlay_ids": [] + }, + { + "shot_id": "SC03", + "sequence": 3, + "title": "The dip", + "duration_seconds": 6.5, + "script_excerpt": "The chart dips; Bruno freezes.", + "camera": { "shot_type": "MLS", "angle": "EYE_LEVEL", "movement": "HANDHELD" }, + "action": "Bruno freezes mid-celebration; papers hang in the air.", + "dialogue": [ + { "order": 1, "character_key": "bruno_bull", "text": "It's still green… right?", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bruno_bull", "boris_bear"], + "scene_key": "the_burrow", + "prop_keys": ["wall_btc_chart"], + "costume_keys": [], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Resistance line at the same screen height as SC01.", + "metadata": { "beat": "escalation" }, + "beginning_state": "Celebration at maximum; chart at local high.", + "ending_state": "Bruno statue-still; chart visibly lower.", + "generation_risks": ["duplicate characters", "full-frame red wash", "wardrobe change"], + "regeneration_fallback": null, + "overlay_ids": ["ov_chart_label_02"] + }, + { + "shot_id": "SC04", + "sequence": 4, + "title": "Before the close", + "duration_seconds": 4.5, + "script_excerpt": "Milo lowers his mug and reveals the delivery.", + "camera": { "shot_type": "CU", "angle": "EYE_LEVEL", "movement": "PAN" }, + "action": "Milo lowers the mug, glances at his phone, slow blink.", + "dialogue": [ + { "order": 1, "character_key": "milo_cat", "text": "Your confetti arrives before candle close.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["milo_cat", "bruno_bull", "boris_bear"], + "scene_key": "the_burrow", + "prop_keys": ["milo_phone"], + "costume_keys": ["costume_milo_office"], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Teal turtleneck; pin on left chest; phone glow only, no legible text.", + "metadata": { "beat": "punchline" }, + "beginning_state": "Mug at lips; phone face-up, screen glow only.", + "ending_state": "Mug at chest height; gaze level; tail settled.", + "generation_risks": ["legible phone text", "stripe or eye-colour drift", "glasses on Milo"], + "regeneration_fallback": { "camera_movement": "STATIC", "note": "Recovery only if identity drifts during the pan." }, + "overlay_ids": ["ov_phone_notification"] + } + ], + "metadata": { + "created_at": "2026-01-02T09:05:00Z", + "generator": "cas-test-fixture", + "model": "", + "prompt_version": "ep001-fixture-v1.1", + "tags": ["fixture", "ep001-shaped", "not-production"] + }, + "output": { + "aspect_ratio": "9:16", + "width": 1080, + "height": 1920, + "fps": 30, + "orientation": "vertical", + "generated_footage_ms": 21000, + "total_runtime_ms": 24000, + "safe_area": { "subtitle_bottom_pct": 18, "margin_pct": 6 } + }, + "localization": { + "spoken_language": "en", + "required_publish_language_tags": ["zh-Hant"], + "subtitle_tracks": [ + { + "language_tag": "zh-Hant", + "is_primary": true, + "rendering": "post_production", + "cues": [ + { "cue_id": "c1", "start_ms": 400, "end_ms": 2000, "text": "突破了!我們回來了!", "speaker_character_key": "bruno_bull", "shot_id": "SC01" }, + { "cue_id": "c2", "start_ms": 3400, "end_ms": 5400, "text": "這根K棒還沒收。", "speaker_character_key": "boris_bear", "shot_id": "SC02" }, + { "cue_id": "c3", "start_ms": 11000, "end_ms": 12800, "text": "還是綠的……對吧?", "speaker_character_key": "bruno_bull", "shot_id": "SC03" }, + { "cue_id": "c4", "start_ms": 17200, "end_ms": 19600, "text": "你的彩帶會比收盤先到。", "speaker_character_key": "milo_cat", "shot_id": "SC04" } + ] + } + ] + }, + "fact_card": { + "duration_ms": 3000, + "placement": "append_after_shots", + "readable_text_in_post": true, + "localized": [ + { + "language_tag": "en", + "body": [ + "Moving above 71,500 is the initial breakout signal — not a confirmed breakout.", + "Some traders wait for the 4h candle to close above it and follow through.", + "Confirmation criteria don't guarantee future performance." + ], + "disclaimer": "For education and entertainment, not financial advice.", + "cta": null + }, + { + "language_tag": "zh-Hant", + "body": [ + "價格站上 71,500 只是初步突破訊號,不等於已確認突破。", + "部分交易者會等 4h K棒收在其上並延續。", + "確認條件並不保證未來表現。" + ], + "disclaimer": "僅供教育與娛樂,非投資建議。", + "cta": null + } + ] + }, + "market_data": { + "instrument": "BTC-USD", + "timeframe": "4h", + "resistance_level": "71,500.00", + "price": "$71,842.10", + "price_move_pct": "2.4%", + "pullback_pct": "-0.8%", + "event_timestamp_utc": "2026-01-02T08:40:00Z", + "candle_close_timestamp_utc": "2026-01-02T12:00:00Z", + "as_of_utc": "2026-01-02T08:45:00Z", + "source_name": "Example Exchange (test)", + "source_url": "https://example-exchange.test/markets/btc-usd", + "factual_note": "Price moved above the level; confirmation outstanding at capture time.", + "ath_context": null, + "data_lock": { "status": "locked", "locked_at_utc": "2026-01-02T08:50:00Z" } + }, + "references": { + "bible_version": "1.0", + "canon_decision": "ADR-015", + "characters": [ + { "character_key": "bruno_bull", "scene_key": null, "prop_key": null, "asset_id": "cas/bruno/identity/front", "kind": "identity", "view": "front", "path": "assets/cas/bruno/front.png" }, + { "character_key": "boris_bear", "scene_key": null, "prop_key": null, "asset_id": "cas/boris/identity/front", "kind": "identity", "view": "front", "path": "assets/cas/boris/front.png" }, + { "character_key": "milo_cat", "scene_key": null, "prop_key": null, "asset_id": "cas/milo/identity/front", "kind": "identity", "view": "front", "path": "assets/cas/milo/front.png" } + ], + "environments": [ + { "character_key": null, "scene_key": "the_burrow", "prop_key": null, "asset_id": "cas/env/the_burrow/master_wide", "kind": "identity", "view": null, "path": null } + ], + "props": [ + { "character_key": null, "scene_key": null, "prop_key": "wall_btc_chart", "asset_id": "cas/prop/wall_chart/textless", "kind": "identity", "view": null, "path": null } + ] + }, + "post_production": { + "overlays": [ + { "overlay_id": "ov_chart_label_01", "type": "chart_label", "shot_id": "SC01", "start_ms": 600, "end_ms": 3000, "required": false, "anchor": "upper_safe", "localized": [ { "language_tag": "en", "text": "71,500" } ] }, + { "overlay_id": "ov_chart_label_02", "type": "chart_label", "shot_id": "SC03", "start_ms": 10200, "end_ms": 13000, "required": false, "anchor": "upper_safe", "localized": [ { "language_tag": "en", "text": "-0.8%" } ] }, + { "overlay_id": "ov_phone_notification", "type": "notification", "shot_id": "SC04", "start_ms": 17000, "end_ms": 19000, "required": false, "anchor": "prop_local", "localized": [ { "language_tag": "en", "text": "Delivery arriving" }, { "language_tag": "zh-Hant", "text": "外送即將送達" } ] }, + { "overlay_id": "ov_fact_card", "type": "fact_card", "shot_id": null, "start_ms": 21000, "end_ms": 24000, "required": true, "anchor": "centre", "localized": [] }, + { "overlay_id": "ov_disclaimer", "type": "disclaimer", "shot_id": null, "start_ms": 21000, "end_ms": 24000, "required": true, "anchor": "lower_safe", "localized": [] } + ] + } +} diff --git a/backend/tests/support/fake_storage.py b/backend/tests/support/fake_storage.py new file mode 100644 index 00000000..e229ae0e --- /dev/null +++ b/backend/tests/support/fake_storage.py @@ -0,0 +1,62 @@ +"""内存对象存储替身(测试用)。 + +用于替换 ``app.core.storage`` 的模块级函数,使字幕产物相关测试无需真实 S3/RustFS。 +刻意保留真实模块的关键行为: +- ``get_file_info`` 对不存在的 key 抛异常(生产用它判断对象是否已存在); +- ``upload_file`` 覆盖同名 key(确定性键的「覆盖而非新增」语义); +- ``delete_file`` 对不存在的 key 静默通过(补偿清理可重复执行)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FakeStorage: + """记录所有对象与调用次数的内存存储。""" + + objects: dict[str, bytes] = field(default_factory=dict) + upload_calls: list[str] = field(default_factory=list) + delete_calls: list[str] = field(default_factory=list) + #: 设为某个 key 时,对该 key 的上传会抛错(用于测试补偿清理)。 + fail_upload_key: str | None = None + + async def upload_file( + self, *, key: str, data: bytes, content_type: str | None = None, **_: Any + ) -> dict[str, Any]: + """写入(或覆盖)一个对象。""" + self.upload_calls.append(key) + if self.fail_upload_key is not None and key == self.fail_upload_key: + raise RuntimeError(f"injected upload failure for {key}") + self.objects[key] = bytes(data) + return {"key": key, "size": len(data), "content_type": content_type} + + async def download_file(self, *, key: str) -> bytes: + """读取对象;不存在则抛错。""" + if key not in self.objects: + raise FileNotFoundError(key) + return self.objects[key] + + async def get_file_info(self, *, key: str) -> dict[str, Any]: + """对象元信息;不存在则抛错(与真实实现一致)。""" + if key not in self.objects: + raise FileNotFoundError(key) + return {"key": key, "size": len(self.objects[key])} + + async def delete_file(self, *, key: str) -> None: + """删除对象;不存在也不报错。""" + self.delete_calls.append(key) + self.objects.pop(key, None) + + def install(self, monkeypatch, module) -> "FakeStorage": + """把本替身的方法打到 ``app.core.storage`` 模块上。""" + monkeypatch.setattr(module, "upload_file", self.upload_file) + monkeypatch.setattr(module, "download_file", self.download_file) + monkeypatch.setattr(module, "get_file_info", self.get_file_info) + monkeypatch.setattr(module, "delete_file", self.delete_file) + return self + + +__all__ = ["FakeStorage"] diff --git a/backend/tests/test_api_response_envelopes.py b/backend/tests/test_api_response_envelopes.py index e1510ab8..ffc4c631 100644 --- a/backend/tests/test_api_response_envelopes.py +++ b/backend/tests/test_api_response_envelopes.py @@ -113,7 +113,7 @@ def test_delete_prompt_template_returns_empty_envelope(client: TestClient) -> No assert response.status_code == 200 body = response.json() - assert body == {"code": 200, "message": "success", "data": None} + assert body == {"code": 200, "message": "success", "data": None, "meta": None} assert "tpl-delete" not in db.items @@ -127,7 +127,7 @@ def test_get_prompt_template_not_found_returns_api_response(client: TestClient) assert response.status_code == 404 body = response.json() - assert body == {"code": 404, "message": "PromptTemplate not found", "data": None} + assert body == {"code": 404, "message": "PromptTemplate not found", "data": None, "meta": None} def test_create_prompt_template_validation_error_returns_api_response(client: TestClient) -> None: diff --git a/backend/tests/test_cas_api_v11_ingestion.py b/backend/tests/test_cas_api_v11_ingestion.py new file mode 100644 index 00000000..56d3448b --- /dev/null +++ b/backend/tests/test_cas_api_v11_ingestion.py @@ -0,0 +1,229 @@ +"""既有 API 端点的 v1.1 摄入测试(Step 4.6)。 + +验证既有请求路径同时接受 schema_version 1.0 与 1.1,且: +- v1 行为与之前完全一致; +- 缺失版本仍产生 missing 错误(422); +- 未知版本显式 422; +- v1.1-only 字段不会被当作 v1 静默接受; +- 与版本无关的既有请求行为不变。 + +使用最小 FastAPI app 挂载既有 CAS 路由并覆盖 get_db(内存 SQLite),不新增任何路由。 +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator, Iterator +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core.db import Base +from app.crypto_animal_studio.api import router as cas_router +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage, EpisodePackageV11 +from app.crypto_animal_studio.schemas.import_request import ImportEpisodeRequest +from app.crypto_animal_studio.schemas.production import ( + CreateProductionJobRequest, + RetryProductionJobRequest, +) +from app.dependencies import get_db + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_V1_SAMPLE = _REPO_ROOT / "samples" / "cas" / "demo_episode.json" +_V11_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "cas" / "ep001_shaped_v11.json" +_PROD_URL = "/api/v1/crypto-animal-studio/production/jobs" + + +def _v1() -> dict: + return json.loads(_V1_SAMPLE.read_text(encoding="utf-8")) + + +def _v11() -> dict: + return json.loads(_V11_FIXTURE.read_text(encoding="utf-8")) + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]: + """挂载既有 CAS 路由的最小 app;存储根指向 tmp_path。 + + 生命周期纪律:建表与 engine.dispose() 都放在 app lifespan 内,并以上下文管理器方式 + 使用 TestClient,因此 **所有** aiosqlite 连接都在 TestClient 的事件循环里创建与释放。 + 若改为在 fixture 里用独立的 ``asyncio.run()`` 建表,连接所属的 worker 线程会绑定到 + 一个随后被关闭的事件循环,在 teardown 时抛出 PytestUnhandledThreadExceptionWarning。 + """ + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.crypto_animal_studio.production.models # noqa: F401 + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async def _get_db() -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + @asynccontextmanager + async def _lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + await engine.dispose() + + monkeypatch.setenv("CAS_STORAGE_ROOT", str(tmp_path)) + app = FastAPI(lifespan=_lifespan) + app.include_router(cas_router, prefix="/api/v1/crypto-animal-studio") + app.dependency_overrides[get_db] = _get_db + with TestClient(app) as test_client: + yield test_client + + +# --------------------------------------------------------------------- # +# 请求模型层:版本分派 +# --------------------------------------------------------------------- # +@pytest.mark.parametrize( + "model,extra", + [ + (CreateProductionJobRequest, {"project_id": "p"}), + (RetryProductionJobRequest, {}), + (ImportEpisodeRequest, {"project_id": "p", "idempotency_key": "k1"}), + ], +) +def test_request_models_accept_both_versions(model, extra: dict) -> None: + """三个既有请求模型都接受 1.0 与 1.1,并绑定到正确的模型类。""" + v1_req = model(episode_package=_v1(), **extra) + assert type(v1_req.episode_package) is EpisodePackage + assert v1_req.episode_package.schema_version == "1.0" + + v11_req = model(episode_package=_v11(), **extra) + assert type(v11_req.episode_package) is EpisodePackageV11 + assert v11_req.episode_package.schema_version == "1.1" + assert v11_req.episode_package.output is not None # v1.1 字段未被静默丢弃 + + +def test_v1_payload_not_upgraded_or_mutated() -> None: + """v1 payload 不被升级也不被修改。""" + data = _v1() + original = json.loads(json.dumps(data)) + request = CreateProductionJobRequest(project_id="p", episode_package=data) + assert data == original + dumped = request.episode_package.model_dump(mode="json") + assert dumped["schema_version"] == "1.0" + for key in ("output", "localization", "fact_card", "market_data", "references", "post_production"): + assert key not in dumped + + +# --------------------------------------------------------------------- # +# HTTP 层:既有端点 +# --------------------------------------------------------------------- # +def test_existing_endpoint_accepts_v1_exactly_as_before(client: TestClient) -> None: + """既有端点对 v1 payload 的行为不变(完成一次 mock 生产)。""" + resp = client.post(_PROD_URL, json={"project_id": "demo", "episode_package": _v1(), "mode": "mock"}) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["status"] == "completed" + assert len(data["shots"]) == len(_v1()["shots"]) + + +def test_existing_endpoint_accepts_full_v11(client: TestClient) -> None: + """同一端点接受完整 v1.1 payload。""" + resp = client.post(_PROD_URL, json={"project_id": "demo", "episode_package": _v11(), "mode": "mock"}) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["status"] == "completed" + assert len(data["shots"]) == 4 + + +def test_missing_schema_version_fails_with_missing_error(client: TestClient) -> None: + """缺失 schema_version → 422,且错误类型仍为 missing。""" + payload = _v1() + payload.pop("schema_version") + resp = client.post(_PROD_URL, json={"project_id": "demo", "episode_package": payload, "mode": "mock"}) + assert resp.status_code == 422 + # 本最小测试 app 未注册 app.main 的异常处理器,因此这里是 FastAPI 默认的 + # {"detail": [...]} 形状;生产环境由 app.main 统一包装为 ApiResponse。 + body = json.dumps(resp.json(), ensure_ascii=False) + assert "schema_version" in body and "missing" in body + + +def test_unknown_schema_version_fails_422(client: TestClient) -> None: + """未知版本 → 显式 422(不被强制升级为 1.1)。""" + payload = _v1() + payload["schema_version"] = "2.0" + resp = client.post(_PROD_URL, json={"project_id": "demo", "episode_package": payload, "mode": "mock"}) + assert resp.status_code == 422 + body = json.dumps(resp.json(), ensure_ascii=False) + assert "schema_version" in body + + +def test_v11_only_fields_not_silently_accepted_as_v1(client: TestClient) -> None: + """带 v1.1-only 字段但声明 1.0 → 422(不会被当作 v1 静默接受)。""" + payload = _v1() + payload["output"] = {"aspect_ratio": "9:16", "fps": 30} + resp = client.post(_PROD_URL, json={"project_id": "demo", "episode_package": payload, "mode": "mock"}) + assert resp.status_code == 422 + + +def test_unrelated_request_behaviour_unchanged(client: TestClient) -> None: + """与版本无关的既有行为不变:未知请求体字段仍 422;未知 job 仍 404。""" + resp = client.post( + _PROD_URL, json={"project_id": "demo", "episode_package": _v1(), "mode": "mock", "surprise": 1} + ) + assert resp.status_code == 422 + assert client.get(f"{_PROD_URL}/does-not-exist").status_code == 404 + + +def test_async_import_endpoint_registers_task( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """POST /import/async 受理 v1.1 文档、登记任务并走既有 Celery 入队通道。""" + import app.tasks.execute_task as execute_task + + enqueued: list[str] = [] + monkeypatch.setattr( + execute_task, "enqueue_task_execution", lambda task_id: enqueued.append(task_id) + ) + + resp = client.post( + "/api/v1/crypto-animal-studio/import/async", + json={"project_id": "demo", "episode_package": _v11(), "idempotency_key": "k-async"}, + ) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["task_kind"] == "cas_import_episode_package" + assert data["relation_type"] == "cas_episode_import" + assert data["reused"] is False + assert data["task_id"] + assert len(data["relation_entity_id"]) == 64 + # 使用既有入队机制,而不是进程内直接执行。 + assert enqueued == [data["task_id"]] + + +def test_async_import_endpoint_rejects_unknown_version(client: TestClient) -> None: + """异步端点与同步端点共用请求模型,因此未知版本同样 422。""" + payload = _v1() + payload["schema_version"] = "2.0" + resp = client.post( + "/api/v1/crypto-animal-studio/import/async", + json={"project_id": "demo", "episode_package": payload, "idempotency_key": "k-bad"}, + ) + assert resp.status_code == 422 + + +def test_openapi_exposes_both_package_schemas(client: TestClient) -> None: + """OpenAPI 仍有清晰表示:请求体为两个 EpisodePackage schema 的 anyOf。""" + spec = client.get("/openapi.json").json() + body = spec["paths"]["/api/v1/crypto-animal-studio/production/jobs"]["post"]["requestBody"] + ref = body["content"]["application/json"]["schema"]["$ref"].split("/")[-1] + field = spec["components"]["schemas"][ref]["properties"]["episode_package"] + refs = {item["$ref"].split("/")[-1] for item in field["anyOf"]} + assert refs == {"EpisodePackage", "EpisodePackageV11"} diff --git a/backend/tests/test_cas_ep001_vertical_slice.py b/backend/tests/test_cas_ep001_vertical_slice.py new file mode 100644 index 00000000..2b69e0a6 --- /dev/null +++ b/backend/tests/test_cas_ep001_vertical_slice.py @@ -0,0 +1,595 @@ +"""EP001 生产纵切测试:EpisodePackage v1.1 → Jellyfish 可编辑生产实体。 + +覆盖 Step 5 要求的全部验收点: +- 生产版 EP001 包可加载; +- schema 契约 + CAS QA 五阶段校验; +- 完整实体映射(Project/Chapter/Shot/ShotDetail/ShotDialogLine/links); +- Character 与 Actor 的区分与关联; +- 镜头顺序与对白顺序/内容保真; +- 英文对白与 zh-Hant 字幕的保真(字幕按既定决策留在契约侧,见下方说明); +- 二次导入幂等; +- 事务回滚(QA 失败与运行期失败都零写入); +- 异步任务的成功与失败状态。 + +事件循环纪律:每个测试用一次 ``asyncio.run``,并在同一次运行内 ``engine.dispose()``, +避免 aiosqlite worker 线程绑定到已关闭的事件循环。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +import app.crypto_animal_studio.application.import_episode as ie +from app.core import storage as core_storage +from app.core.db import Base, async_session_maker +from app.core.task_manager.types import TaskStatus +from app.crypto_animal_studio.application.hashing import canonical_payload_hash +from app.crypto_animal_studio.application.import_episode import ( + CasValidationError, + import_episode, +) +from app.crypto_animal_studio.application.import_tasks import ( + CAS_EPISODE_IMPORT_RELATION_TYPE, + CAS_IMPORT_EPISODE_TASK_KIND, + create_cas_import_task, + episode_relation_entity_id, + run_cas_import_task, +) +from app.crypto_animal_studio.application.parsing import parse_episode_package +from app.crypto_animal_studio.application.validation import ( + ValidationStage, + derived_runtime_for, + validate_episode_package, +) +from app.crypto_animal_studio.domain.import_ledger import CasImportLedger +from app.crypto_animal_studio.schemas.episode_package import EpisodePackageV11 +from app.models.studio import ( + Actor, + Chapter, + Character, + Project, + Shot, + ShotCharacterLink, + ShotDetail, + ShotDialogLine, +) +from app.models.studio_file_usages import FileUsage +from app.models.studio_prompts_files_timeline import FileItem +from app.models.task import GenerationTask +from app.models.task_links import GenerationTaskLink +from app.models.types import FileType, FileUsageKind, ProjectStyle, ProjectVisualStyle +from tests.support.fake_storage import FakeStorage + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_EP001 = _REPO_ROOT / "samples" / "cas" / "ep001_btc_breakout.json" + +#: 审定文档锁定的英文对白(EP001 §6 对白表),按镜头顺序。 +_EXPECTED_ENGLISH = [ + "Breakout! We are so back!", + "The candle hasn't closed yet.", + "It's still green… right?", + "Your confetti arrives before candle close.", +] + +#: 审定文档锁定的 zh-Hant 字幕,按 cue 顺序。 +_EXPECTED_ZH_HANT = [ + "突破了!我們回來了!", + "這根K棒還沒收。", + "還是綠的……對吧?", + "你的彩帶會比收盤先到。", +] + + +@pytest.fixture(autouse=True) +def fake_storage(monkeypatch: pytest.MonkeyPatch) -> FakeStorage: + """所有测试都跑在内存对象存储上:字幕产物写入无需真实 S3/RustFS。""" + return FakeStorage().install(monkeypatch, core_storage) + + +def _ep001_dict() -> dict: + """读取生产版 EP001 包。""" + return json.loads(_EP001.read_text(encoding="utf-8")) + + +def _ep001_package() -> EpisodePackageV11: + """解析生产版 EP001 包。""" + return parse_episode_package(_ep001_dict()) + + +async def _make_sessionmaker(): + """建内存 SQLite(StaticPool 共享单连接)并建表,返回 (engine, Session)。""" + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + import app.crypto_animal_studio.domain.import_ledger # noqa: F401 + import app.models.llm # noqa: F401 + import app.models.studio # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +async def _seed_project(session_factory, project_id: str = "cas-series-1") -> str: + """建一个代表系列/季的 Project 容器(不新增 Episode 表)。""" + async with session_factory() as db: + db.add( + Project( + id=project_id, + name="Crypto Animal Studio — Block Street (Season 1)", + style=ProjectStyle.anime_3d, + visual_style=ProjectVisualStyle.anime, + ) + ) + await db.commit() + return project_id + + +async def _count(session_factory, model) -> int: + """统计某表行数。""" + async with session_factory() as db: + return int((await db.execute(select(func.count()).select_from(model))).scalar() or 0) + + +# --------------------------------------------------------------------------- # +# 1. 包加载与契约 +# --------------------------------------------------------------------------- # +def test_ep001_package_loads_as_v11() -> None: + """生产包按 v1.1 解析,身份与规格符合审定文档。""" + package = _ep001_package() + assert type(package) is EpisodePackageV11 + assert package.schema_version == "1.1" + assert package.episode_id == "CAS-EP001" + assert package.title == "BTC Breaks Out — Bruno Celebrates Too Early" + assert len(package.shots) == 4 + assert package.output.aspect_ratio == "9:16" + assert package.output.orientation == "vertical" + assert package.language == "en" + assert package.localization.spoken_language == "en" + assert package.localization.required_publish_language_tags == ["zh-Hant"] + + +def test_ep001_runtime_matches_approved_24_seconds() -> None: + """派生时长为审定文档锁定的 24.0 秒,且落在 15–30 秒发布区间内。""" + package = _ep001_package() + assert derived_runtime_for(package).total_ms == 24_000 + assert package.output.total_runtime_ms == 24_000 + assert [s.duration_seconds for s in sorted(package.shots, key=lambda s: s.sequence)] == [ + 3.0, + 7.0, + 6.5, + 4.5, + ] + + +def test_ep001_uses_canonical_cast_only() -> None: + """只使用 Bible v1 的三位主角,绝不出现替身角色。""" + keys = {c.character_key for c in _ep001_package().characters} + assert keys == {"bruno_bull", "boris_bear", "milo_cat"} + assert "walter" not in {k.lower() for k in keys} + + +def test_ep001_passes_every_validation_stage() -> None: + """CAS QA 五阶段全部通过(含 publish 的时长与禁用措辞检查)。""" + package = _ep001_package() + for stage in ValidationStage: + result = validate_episode_package(package, stage=stage) + assert result.ok, f"stage {stage.value} failed: {[i.code for i in result.errors]}" + + +# --------------------------------------------------------------------------- # +# 2. 完整实体映射 +# --------------------------------------------------------------------------- # +def test_import_creates_complete_entity_graph() -> None: + """一次导入建立 Chapter→Shot→ShotDetail→ShotDialogLine 的完整图,且只建一个 Chapter。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + result = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.commit() + + assert result.status == "imported" + assert result.chapter_id is not None + assert result.created.chapters == 1 + assert result.created.shots == 4 + assert result.created.shot_details == 4 + assert result.created.dialog_lines == 4 + assert result.created.characters == 3 + + assert await _count(session_factory, Chapter) == 1 + assert await _count(session_factory, Shot) == 4 + assert await _count(session_factory, ShotDetail) == 4 + assert await _count(session_factory, ShotDialogLine) == 4 + assert await _count(session_factory, CasImportLedger) == 1 + + async with session_factory() as db: + chapter = (await db.execute(select(Chapter))).scalars().one() + assert chapter.project_id == pid + assert chapter.title == "BTC Breaks Out — Bruno Celebrates Too Early" + assert chapter.storyboard_count == 4 + # raw_text 保留完整剧本,供追溯(决策 4)。 + assert "Breakout! We are so back!" in chapter.raw_text + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_character_and_actor_are_linked_but_distinct() -> None: + """Character(项目内角色)与 Actor(可复用视觉身份)分开建模并正确关联。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.commit() + + async with session_factory() as db: + characters = (await db.execute(select(Character))).scalars().all() + actors = (await db.execute(select(Actor))).scalars().all() + + assert len(characters) == 3 + assert len(actors) == 3 + assert {c.name for c in characters} == {"Bruno Bull", "Boris Bear", "Milo Cat"} + # 每个角色都归属该项目,并链接到一个独立的 Actor 身份。 + for character in characters: + assert character.project_id == pid + assert character.actor_id is not None + # Character 与 Actor 是两套 ID,绝不合并。 + assert {c.id for c in characters}.isdisjoint({a.id for a in actors}) + assert len({c.actor_id for c in characters}) == 3 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_shot_order_and_dialogue_are_preserved() -> None: + """镜头按 sequence 落到 Shot.index,对白顺序与文本逐字保真。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.commit() + + async with session_factory() as db: + shots = (await db.execute(select(Shot).order_by(Shot.index))).scalars().all() + assert [s.index for s in shots] == [1, 2, 3, 4] + assert [s.title for s in shots] == [ + "The premature toast", + "Confirmation, please", + "The dip", + "Before the close", + ] + + texts: list[str] = [] + for shot in shots: + lines = ( + ( + await db.execute( + select(ShotDialogLine) + .where(ShotDialogLine.shot_detail_id == shot.id) + .order_by(ShotDialogLine.index) + ) + ) + .scalars() + .all() + ) + texts.extend(line.text for line in lines) + assert texts == _EXPECTED_ENGLISH + + links = (await db.execute(select(ShotCharacterLink))).scalars().all() + assert len(links) > 0 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_english_dialogue_and_zh_hant_subtitles_are_preserved() -> None: + """英文对白进入可编辑实体;zh-Hant 字幕在契约侧保真并被幂等哈希覆盖。 + + 经批准的决策:Jellyfish 当前没有任何字幕/语言列(``shot_dialog_lines`` 只有单一 + ``text``),因此本切片**不**新增迁移。字幕留在 EpisodePackage 与 CAS 生产产物中; + 这里断言两件可验证的事: + 1. 数据库里的对白确实是英文原文(不是被字幕覆盖); + 2. zh-Hant 字幕轨完整、与镜头正确关联,并被纳入幂等 payload 哈希—— + 改动任一字幕都会改变哈希,因此字幕不可能被静默丢弃。 + """ + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.commit() + + async with session_factory() as db: + lines = ( + (await db.execute(select(ShotDialogLine).order_by(ShotDialogLine.id))) + .scalars() + .all() + ) + # 1. 落库的是英文对白。 + assert sorted(line.text for line in lines) == sorted(_EXPECTED_ENGLISH) + for line in lines: + assert line.speaker_name in {"Bruno Bull", "Boris Bear", "Milo Cat"} + finally: + await engine.dispose() + + asyncio.run(_run()) + + # 2. 字幕轨在契约侧完整且与镜头关联。 + package = _ep001_package() + tracks = package.localization.subtitle_tracks + assert len(tracks) == 1 + track = tracks[0] + assert track.language_tag == "zh-Hant" + assert [cue.text for cue in track.cues] == _EXPECTED_ZH_HANT + shot_ids = {shot.shot_id for shot in package.shots} + for cue in track.cues: + assert cue.shot_id in shot_ids + assert cue.end_ms > cue.start_ms + + # 3. 字幕纳入幂等哈希:改一个字就换哈希。 + mutated = _ep001_dict() + mutated["localization"]["subtitle_tracks"][0]["cues"][0]["text"] = "改過的字幕" + assert canonical_payload_hash(parse_episode_package(mutated)) != canonical_payload_hash(package) + + +# --------------------------------------------------------------------------- # +# 3. 幂等 +# --------------------------------------------------------------------------- # +def test_second_import_is_idempotent() -> None: + """同一 (project, key, payload) 重复导入不产生任何重复实体。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + first = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.commit() + + async with session_factory() as db: + second = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.commit() + + assert first.status == "imported" + assert second.status == "replayed" + assert second.idempotent_replay is True + assert second.chapter_id == first.chapter_id + + assert await _count(session_factory, Chapter) == 1 + assert await _count(session_factory, Shot) == 4 + assert await _count(session_factory, ShotDetail) == 4 + assert await _count(session_factory, ShotDialogLine) == 4 + assert await _count(session_factory, Character) == 3 + assert await _count(session_factory, Actor) == 3 + assert await _count(session_factory, CasImportLedger) == 1 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- # +# 4. 事务与回滚 +# --------------------------------------------------------------------------- # +def test_qa_gate_failure_creates_no_rows() -> None: + """QA 闸门失败发生在建实体之前 → 零写入。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + broken = _ep001_dict() + # 让事实处于未锁定状态:data-lock 阶段必须拒绝。 + broken["market_data"]["data_lock"]["status"] = "unresolved" + package = parse_episode_package(broken) + + async with session_factory() as db: + with pytest.raises(CasValidationError) as excinfo: + await import_episode( + db, project_id=pid, package=package, idempotency_key="ep001-bad" + ) + await db.rollback() + + assert excinfo.value.stage is ValidationStage.pre_render_data_lock + assert "data_lock_required" in {issue.code for issue in excinfo.value.issues} + + assert await _count(session_factory, Chapter) == 0 + assert await _count(session_factory, Shot) == 0 + assert await _count(session_factory, ShotDialogLine) == 0 + assert await _count(session_factory, Character) == 0 + assert await _count(session_factory, CasImportLedger) == 0 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_failure_midway_rolls_back_completely(monkeypatch: pytest.MonkeyPatch) -> None: + """导入中途抛错 → 整个事务回滚,不留任何部分写入。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + real_round = ie.mapping.round_duration + calls = {"n": 0} + + def _boom(seconds: float) -> int: + calls["n"] += 1 + if calls["n"] == 3: # 前两镜已写入后再炸,确保确实有部分写入待回滚 + raise RuntimeError("injected failure during shot mapping") + return real_round(seconds) + + monkeypatch.setattr(ie.mapping, "round_duration", _boom) + + async with session_factory() as db: + with pytest.raises(RuntimeError, match="injected failure"): + await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="ep001-k1" + ) + await db.rollback() + + assert await _count(session_factory, Chapter) == 0 + assert await _count(session_factory, Shot) == 0 + assert await _count(session_factory, ShotDetail) == 0 + assert await _count(session_factory, ShotDialogLine) == 0 + assert await _count(session_factory, CasImportLedger) == 0 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- # +# 5. 异步任务 +# --------------------------------------------------------------------------- # +def test_async_task_succeeds_and_imports_episode() -> None: + """cas_import_episode_package 任务跑通:状态 succeeded,且实体确实落库。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + original = async_session_maker._maker # pylint: disable=protected-access + try: + pid = await _seed_project(session_factory) + async_session_maker.configure(session_factory) + + async with session_factory() as db: + created = await create_cas_import_task( + db, + project_id=pid, + episode_package=_ep001_dict(), + idempotency_key="ep001-async", + ) + await db.commit() + + assert created.reused is False + assert created.relation_type == CAS_EPISODE_IMPORT_RELATION_TYPE + assert created.relation_entity_id == episode_relation_entity_id(pid, "CAS-EP001") + + async with session_factory() as db: + task = await db.get(GenerationTask, created.task_id) + assert task is not None + assert task.task_kind == CAS_IMPORT_EPISODE_TASK_KIND + link = (await db.execute(select(GenerationTaskLink))).scalars().one() + assert link.task_id == created.task_id + + await run_cas_import_task(created.task_id) + + async with session_factory() as db: + task = await db.get(GenerationTask, created.task_id) + status_value = ( + task.status.value if hasattr(task.status, "value") else str(task.status) + ) + assert status_value == TaskStatus.succeeded.value + assert not task.error + + assert await _count(session_factory, Chapter) == 1 + assert await _count(session_factory, Shot) == 4 + assert await _count(session_factory, CasImportLedger) == 1 + finally: + async_session_maker.configure(original) + await engine.dispose() + + asyncio.run(_run()) + + +def test_async_task_records_failure_state() -> None: + """目标项目不存在 → 任务落 failed 且带错误信息,同时零写入。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + original = async_session_maker._maker # pylint: disable=protected-access + try: + async_session_maker.configure(session_factory) + + async with session_factory() as db: + created = await create_cas_import_task( + db, + project_id="no-such-project", + episode_package=_ep001_dict(), + idempotency_key="ep001-async-fail", + ) + await db.commit() + + await run_cas_import_task(created.task_id) + + async with session_factory() as db: + task = await db.get(GenerationTask, created.task_id) + status_value = ( + task.status.value if hasattr(task.status, "value") else str(task.status) + ) + assert status_value == TaskStatus.failed.value + assert "Project not found" in task.error + + assert await _count(session_factory, Chapter) == 0 + assert await _count(session_factory, CasImportLedger) == 0 + finally: + async_session_maker.configure(original) + await engine.dispose() + + asyncio.run(_run()) + + +def test_async_task_reuses_active_task_for_same_episode() -> None: + """同一 (project, episode) 已有活动任务时复用,不重复登记。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + first = await create_cas_import_task( + db, project_id=pid, episode_package=_ep001_dict(), idempotency_key="a" + ) + await db.commit() + async with session_factory() as db: + second = await create_cas_import_task( + db, project_id=pid, episode_package=_ep001_dict(), idempotency_key="b" + ) + await db.commit() + + assert first.reused is False + assert second.reused is True + assert second.task_id == first.task_id + assert await _count(session_factory, GenerationTask) == 1 + finally: + await engine.dispose() + + asyncio.run(_run()) diff --git a/backend/tests/test_cas_episode_package_v11.py b/backend/tests/test_cas_episode_package_v11.py new file mode 100644 index 00000000..4797398f --- /dev/null +++ b/backend/tests/test_cas_episode_package_v11.py @@ -0,0 +1,686 @@ +"""EpisodePackage v1.1 测试:版本分派、时长派生、字幕、市场事实、叠加时间、供应商中立性。 + +全部离线、确定性。既有 v1 样本与 v1 测试保持不变。 +""" + +from __future__ import annotations + +import copy +import json +from decimal import Decimal +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app.crypto_animal_studio.application.hashing import canonical_payload_hash +from app.crypto_animal_studio.application.parsing import ( + UnsupportedSchemaVersionError, + parse_episode_package, +) +from app.crypto_animal_studio.application.validation import ( + PUBLISH_MAX_TOTAL_MS, + SHOT_ASSOCIATION_TOLERANCE_MS, + ValidationStage, + derived_runtime_for, + validate_episode_package, +) +from app.crypto_animal_studio.domain import market_facts, provider_safety +from app.crypto_animal_studio.domain.episode_package import SUPPORTED_SCHEMA_VERSIONS +from app.crypto_animal_studio.domain.runtime import derive_runtime, round_half_up, seconds_to_ms +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage, EpisodePackageV11 + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_V1_SAMPLE = _REPO_ROOT / "samples" / "cas" / "demo_episode.json" +_V11_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "cas" / "ep001_shaped_v11.json" + + +def _v1() -> dict: + return json.loads(_V1_SAMPLE.read_text(encoding="utf-8")) + + +def _v11() -> dict: + return json.loads(_V11_FIXTURE.read_text(encoding="utf-8")) + + +def _validate(data: dict, stage: ValidationStage): + return validate_episode_package(parse_episode_package(data), stage=stage) + + +# --------------------------------------------------------------------- # +# 版本兼容 +# --------------------------------------------------------------------- # +def test_supported_versions_are_explicit() -> None: + assert SUPPORTED_SCHEMA_VERSIONS == frozenset({"1.0", "1.1"}) + + +def test_v1_sample_still_parses_and_is_v1_model() -> None: + package = parse_episode_package(_v1()) + assert isinstance(package, EpisodePackage) + assert not isinstance(package, EpisodePackageV11) + assert package.schema_version == "1.0" + + +def test_v1_payload_hash_unchanged_under_new_parser() -> None: + """v1 文档的规范化哈希不因新解析器而改变(与直接用 v1 模型一致)。""" + data = _v1() + assert canonical_payload_hash(parse_episode_package(data)) == canonical_payload_hash( + EpisodePackage.model_validate(data) + ) + + +def test_no_silent_version_upgrade() -> None: + """读取 v1 不会把 schema_version 改写为 1.1,也不会注入新可选对象。""" + package = parse_episode_package(_v1()) + dumped = package.model_dump(mode="json") + assert dumped["schema_version"] == "1.0" + for key in ("output", "localization", "fact_card", "market_data", "references", "post_production"): + assert key not in dumped + + +def test_full_v11_package_parses() -> None: + package = parse_episode_package(_v11()) + assert isinstance(package, EpisodePackageV11) + assert package.output is not None and package.localization is not None + + +def test_v11_with_all_optional_objects_omitted_parses() -> None: + data = _v11() + for key in ("output", "localization", "fact_card", "market_data", "references", "post_production"): + data.pop(key, None) + package = parse_episode_package(data) + assert isinstance(package, EpisodePackageV11) + assert package.output is None and package.fact_card is None + + +def test_missing_version_keeps_existing_missing_field_error() -> None: + data = _v1() + data.pop("schema_version") + with pytest.raises(ValidationError) as info: + parse_episode_package(data) + assert any(err["loc"] == ("schema_version",) and err["type"] == "missing" for err in info.value.errors()) + + +def test_unknown_version_fails_explicitly() -> None: + data = _v1() + data["schema_version"] = "2.0" + with pytest.raises(UnsupportedSchemaVersionError): + parse_episode_package(data) + + +def test_v11_payload_rejected_by_v1_model() -> None: + """v1 模型不接受 1.1(显式分派,不会"意外"同时接受两个版本)。""" + with pytest.raises(ValidationError): + EpisodePackage.model_validate(_v11()) + + +def test_extra_fields_still_forbidden_in_v11() -> None: + data = _v11() + data["surprise"] = 1 + with pytest.raises(ValidationError): + parse_episode_package(data) + + +# --------------------------------------------------------------------- # +# 时长派生与断言 +# --------------------------------------------------------------------- # +def test_round_half_up_boundaries() -> None: + assert round_half_up(Decimal("0.5")) == 1 + assert round_half_up(Decimal("1.5")) == 2 # 银行家取整会得到 2 -> 这里也是 2 + assert round_half_up(Decimal("2.5")) == 3 # 银行家取整会得到 2,本实现必须为 3 + assert round_half_up(Decimal("-2.5")) == -3 + assert seconds_to_ms(3.0005) == 3001 + assert seconds_to_ms(2.9995) == 3000 + + +def test_ep001_shaped_runtime_is_24000() -> None: + package = parse_episode_package(_v11()) + runtime = derived_runtime_for(package) + assert runtime.per_shot_ms == (3000, 7000, 6500, 4500) + assert runtime.generated_ms == 21000 + assert runtime.fact_card_ms == 3000 + assert runtime.total_ms == 24000 + + +def test_non_appended_fact_card_does_not_extend_runtime() -> None: + data = _v11() + data["fact_card"]["placement"] = "overlay_tail" + runtime = derived_runtime_for(parse_episode_package(data)) + assert runtime.fact_card_ms == 0 + assert runtime.total_ms == runtime.generated_ms == 21000 + + +def test_assertion_mismatch_exactly_50ms_passes() -> None: + data = _v11() + data["output"]["total_runtime_ms"] = 24050 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "runtime_assertion_mismatch" not in result.codes() + + +def test_assertion_mismatch_51ms_fails() -> None: + data = _v11() + data["output"]["total_runtime_ms"] = 24051 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "runtime_assertion_mismatch" in {issue.code for issue in result.errors} + + +def test_target_duration_mismatch_is_warning_only() -> None: + data = _v11() + data["creative_direction"]["target_duration_seconds"] = 30 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "target_duration_mismatch" in {issue.code for issue in result.warnings} + assert "target_duration_mismatch" not in {issue.code for issue in result.errors} + + +def test_fixture_passes_all_stages() -> None: + for stage in ValidationStage: + result = _validate(_v11(), stage) + assert result.ok, f"{stage}: {[(i.code, i.field_path) for i in result.errors]}" + + +# --------------------------------------------------------------------- # +# 传统 v1 不因缺少新对象而失败 +# --------------------------------------------------------------------- # +def test_legacy_v1_passes_stages_without_new_objects() -> None: + """v1 包不因缺少 localization/字幕/fact card/market_data/references/post_production 而失败。""" + for stage in ( + ValidationStage.design, + ValidationStage.pre_render_data_lock, + ValidationStage.provider_input, + ValidationStage.post_production, + ): + result = _validate(_v1(), stage) + assert result.ok, f"{stage}: {[(i.code, i.field_path) for i in result.errors]}" + + +def test_legacy_v1_publish_only_flags_runtime_not_missing_objects() -> None: + """v1 样本总时长 39s,publish 只应因 15–30s 规则失败,而非因缺少新对象失败。""" + result = _validate(_v1(), ValidationStage.publish) + codes = {issue.code for issue in result.errors} + assert codes == {"runtime_out_of_publish_range"}, codes + + +# --------------------------------------------------------------------- # +# 字幕 +# --------------------------------------------------------------------- # +def test_v11_without_required_language_needs_no_track() -> None: + data = _v11() + data["localization"] = {"spoken_language": "en", "required_publish_language_tags": [], "subtitle_tracks": []} + result = _validate(data, ValidationStage.publish) + assert result.ok, [(i.code, i.field_path) for i in result.errors] + + +def test_missing_required_track_fails_post_production_and_publish() -> None: + data = _v11() + data["localization"]["subtitle_tracks"] = [] + for stage in (ValidationStage.post_production, ValidationStage.publish): + result = _validate(data, stage) + assert "missing_required_subtitle_track" in {i.code for i in result.errors} + # 设计阶段允许(尚在编写) + assert _validate(data, ValidationStage.design).ok + + +def test_empty_required_track_passes_design_fails_post_production() -> None: + data = _v11() + data["localization"]["subtitle_tracks"][0]["cues"] = [] + assert _validate(data, ValidationStage.design).ok + codes = {i.code for i in _validate(data, ValidationStage.post_production).errors} + assert "empty_subtitle_track" in codes or "empty_required_subtitle_track" in codes + + +def test_no_locale_fallback_zh_does_not_satisfy_zh_hant() -> None: + data = _v11() + data["localization"]["subtitle_tracks"][0]["language_tag"] = "zh" + result = _validate(data, ValidationStage.post_production) + assert "missing_required_subtitle_track" in {i.code for i in result.errors} + + +def test_zero_length_cue_rejected_by_schema() -> None: + data = _v11() + data["localization"]["subtitle_tracks"][0]["cues"][0]["end_ms"] = data["localization"]["subtitle_tracks"][0][ + "cues" + ][0]["start_ms"] + with pytest.raises(ValidationError): + parse_episode_package(data) + + +def test_negative_cue_start_rejected_by_schema() -> None: + data = _v11() + data["localization"]["subtitle_tracks"][0]["cues"][0]["start_ms"] = -1 + with pytest.raises(ValidationError): + parse_episode_package(data) + + +def test_cue_beyond_runtime_fails() -> None: + data = _v11() + data["localization"]["subtitle_tracks"][0]["cues"][-1]["end_ms"] = 24001 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "cue_out_of_runtime" in {i.code for i in result.errors} + + +def test_cue_ending_exactly_at_total_runtime_passes() -> None: + data = _v11() + cue = data["localization"]["subtitle_tracks"][0]["cues"][-1] + cue["shot_id"] = None # 落在 fact card 区间,不再关联镜头 + cue["start_ms"] = 23000 + cue["end_ms"] = 24000 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "cue_out_of_runtime" not in {i.code for i in result.errors} + + +def test_cue_overlap_within_track_fails() -> None: + data = _v11() + cues = data["localization"]["subtitle_tracks"][0]["cues"] + cues[1]["start_ms"] = cues[0]["end_ms"] - 10 + cues[1]["shot_id"] = None + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "cue_overlap" in {i.code for i in result.errors} + + +def test_invalid_language_tag_shape_fails_design() -> None: + data = _v11() + data["localization"]["required_publish_language_tags"] = ["zz--bad"] + result = _validate(data, ValidationStage.design) + assert "invalid_language_tag" in {i.code for i in result.errors} + + +def test_language_tag_shapes_accepted() -> None: + for tag in ("en", "zh-Hant", "zh-Hant-TW", "de-DE"): + assert market_facts.is_valid_language_tag(tag), tag + for tag in ("", "e", "toolongtag", "zh_Hant"): + assert not market_facts.is_valid_language_tag(tag), tag + + +# --------------------------------------------------------------------- # +# 市场事实 +# --------------------------------------------------------------------- # +def test_design_stage_allows_placeholders() -> None: + data = _v11() + data["market_data"]["resistance_level"] = "{{RESISTANCE_LEVEL}}" + data["market_data"]["data_lock"] = {"status": "unresolved", "locked_at_utc": None} + assert _validate(data, ValidationStage.design).ok + + +def test_locked_package_with_placeholder_fails_data_lock() -> None: + data = _v11() + data["market_data"]["price"] = "{{BTC_PRICE}}" + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "unresolved_placeholder" in {i.code for i in result.errors} + + +def test_placeholder_fails_publish_stage() -> None: + data = _v11() + data["fact_card"]["localized"][0]["body"][0] = "Above {{RESISTANCE_LEVEL}} is only a signal." + result = _validate(data, ValidationStage.publish) + assert "unresolved_placeholder" in {i.code for i in result.errors} + + +def test_unresolved_status_fails_data_lock() -> None: + data = _v11() + data["market_data"]["data_lock"]["status"] = "unresolved" + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "data_lock_required" in {i.code for i in result.errors} + + +@pytest.mark.parametrize("bad", ["", " ", "NaN", "inf", "infinity", "None", "null", "TBD", "?", "abc"]) +def test_invalid_market_numbers_fail(bad: str) -> None: + data = _v11() + data["market_data"]["price"] = bad + result = _validate(data, ValidationStage.pre_render_data_lock) + assert {i.code for i in result.errors} & {"unparseable_decimal", "missing_required_market_fact"} + + +def test_valid_decimal_percentage_timestamp_parse() -> None: + assert market_facts.parse_decimal("$71,842.10") == Decimal("71842.10") + assert market_facts.parse_percentage("2.4%") == Decimal("2.4") + assert market_facts.parse_percentage("-0.8 %") == Decimal("-0.8") + assert market_facts.parse_iso8601("2026-01-02T08:40:00Z") is not None + assert market_facts.parse_iso8601("not-a-date") is None + + +def test_malformed_timestamp_fails_data_lock() -> None: + data = _v11() + data["market_data"]["as_of_utc"] = "2026-13-45T99:99:99Z" + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "unparseable_timestamp" in {i.code for i in result.errors} + + +def test_validation_does_not_mutate_source_values() -> None: + data = _v11() + snapshot = copy.deepcopy(data) + _validate(data, ValidationStage.publish) + assert data == snapshot + + +def test_placeholder_syntax_is_narrow() -> None: + assert market_facts.contains_placeholder("{{X}}") + assert not market_facts.contains_placeholder("a { brace } in prose") + assert not market_facts.contains_placeholder("f-string like {value}") + + +# --------------------------------------------------------------------- # +# 叠加时间 +# --------------------------------------------------------------------- # +def test_fact_card_overlay_interval_passes() -> None: + result = _validate(_v11(), ValidationStage.post_production) + assert "fact_card_interval_mismatch" not in {i.code for i in result.errors} + + +def test_fact_card_overlay_wrong_start_fails() -> None: + data = _v11() + for overlay in data["post_production"]["overlays"]: + if overlay["type"] == "fact_card": + overlay["start_ms"] = 20000 + result = _validate(data, ValidationStage.post_production) + assert "fact_card_interval_mismatch" in {i.code for i in result.errors} + + +def test_overlay_beyond_runtime_fails() -> None: + data = _v11() + data["post_production"]["overlays"][0]["end_ms"] = 24500 + data["post_production"]["overlays"][0]["shot_id"] = None + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "overlay_out_of_runtime" in {i.code for i in result.errors} + + +def test_negative_overlay_start_rejected_by_schema() -> None: + data = _v11() + data["post_production"]["overlays"][0]["start_ms"] = -1 + with pytest.raises(ValidationError): + parse_episode_package(data) + + +def test_shot_association_within_tolerance_passes() -> None: + data = _v11() + # SC01 窗口 [0,3000];容差内结束 + data["post_production"]["overlays"][0]["end_ms"] = 3000 + SHOT_ASSOCIATION_TOLERANCE_MS + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "shot_association_mismatch" not in {i.code for i in result.errors} + + +def test_shot_association_beyond_tolerance_fails() -> None: + data = _v11() + data["post_production"]["overlays"][0]["end_ms"] = 3000 + SHOT_ASSOCIATION_TOLERANCE_MS + 1 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "shot_association_mismatch" in {i.code for i in result.errors} + + +def test_shot_relative_offset_field_rejected() -> None: + data = _v11() + data["post_production"]["overlays"][0]["offset_ms"] = 100 + with pytest.raises(ValidationError): + parse_episode_package(data) + + +def test_unknown_overlay_reference_fails_design() -> None: + data = _v11() + data["shots"][0]["overlay_ids"] = ["ov_missing"] + result = _validate(data, ValidationStage.design) + assert "unknown_overlay_reference" in {i.code for i in result.errors} + + +# --------------------------------------------------------------------- # +# 供应商中立性 +# --------------------------------------------------------------------- # +def test_allowed_provenance_url_and_asset_paths_pass() -> None: + assert provider_safety.classify_string("https://example-exchange.test/markets/btc-usd") is None + assert provider_safety.classify_string("assets/cas/bruno/front.png") is None + assert provider_safety.classify_string("cas/bruno/identity/front") is None + result = _validate(_v11(), ValidationStage.provider_input) + assert "provider_neutrality_violation" not in {i.code for i in result.errors} + + +@pytest.mark.parametrize( + "value,category", + [ + ("https://api.openai.com/v1/images/generations", "provider_api_endpoint"), + ("https://host.test/x?X-Amz-Signature=abcdef123456", "signed_or_expiring_url"), + ("https://host.test/download?Expires=1699999999", "signed_or_expiring_url"), + ("https://user:secretpw@host.test/asset.png", "credentials_in_url"), + ("https://host.test/accounts/12345/generate", "account_scoped_url"), + ("sk-abcdefghijklmnopqrstuvwxyz012345", "api_key"), + ("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6", "bearer_token"), + ("Authorization: Bearer xyztokenvalue", "authorization_header"), + ], +) +def test_forbidden_url_and_secret_categories_detected(value: str, category: str) -> None: + assert provider_safety.classify_string(value) == category + + +def test_forbidden_value_in_package_fails_and_does_not_echo_secret() -> None: + data = _v11() + secret = "sk-abcdefghijklmnopqrstuvwxyz012345" + data["market_data"]["source_url"] = secret + result = _validate(data, ValidationStage.provider_input) + violations = [i for i in result.errors if i.code == "provider_neutrality_violation"] + assert violations + assert all(secret not in issue.message for issue in violations) + assert any("market_data.source_url" in issue.field_path for issue in violations) + + +def test_provider_native_payload_key_in_freeform_metadata_fails() -> None: + data = _v11() + data["shots"][0]["metadata"]["provider_request"] = {"model": "x"} + result = _validate(data, ValidationStage.design) + assert "provider_neutrality_violation" in {i.code for i in result.errors} + + +# --------------------------------------------------------------------- # +# 发布闸门 +# --------------------------------------------------------------------- # +def test_prohibited_phrase_fails_publish() -> None: + data = _v11() + data["shots"][0]["dialogue"][0]["text"] = "This is guaranteed money." + result = _validate(data, ValidationStage.publish) + assert "prohibited_phrase" in {i.code for i in result.errors} + + +def test_disclaimer_wording_not_flagged() -> None: + """免责声明中的 "not financial advice" 与 "don't guarantee" 属合法用语。""" + result = _validate(_v11(), ValidationStage.publish) + assert "prohibited_phrase" not in {i.code for i in result.errors} + + +def test_runtime_outside_publish_range_fails() -> None: + data = _v11() + data["shots"][0]["duration_seconds"] = 40.0 + data["output"]["generated_footage_ms"] = None + data["output"]["total_runtime_ms"] = None + result = _validate(data, ValidationStage.publish) + assert "runtime_out_of_publish_range" in {i.code for i in result.errors} + assert derive_runtime(parse_episode_package(data).shots, None).generated_ms > PUBLISH_MAX_TOTAL_MS + + +def test_missing_camera_movement_fails_pre_render() -> None: + data = _v11() + data["shots"][0]["camera"]["movement"] = None + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "missing_camera_movement" in {i.code for i in result.errors} + + +def test_fixture_shape_matches_ep001_acceptance_scenarios() -> None: + """夹具体现 EP001 验收要点(四镜、24s、9:16、zh-Hant、Milo 台词、无第五镜)。""" + package = parse_episode_package(_v11()) + assert package.schema_version == "1.1" + assert len(package.shots) == 4 + runtime = derived_runtime_for(package) + assert (runtime.generated_ms, runtime.fact_card_ms, runtime.total_ms) == (21000, 3000, 24000) + assert package.output.aspect_ratio == "9:16" and package.output.fps == 30 + assert package.output.width == 1080 and package.output.height == 1920 + assert package.localization.spoken_language == "en" + assert package.localization.required_publish_language_tags == ["zh-Hant"] + track = package.localization.subtitle_tracks[0] + assert track.language_tag == "zh-Hant" and len(track.cues) == 4 + final_line = package.shots[-1].dialogue[0].text + assert final_line == "Your confetti arrives before candle close." + assert any(o.type == "notification" and not o.required for o in package.post_production.overlays) + assert package.market_data.data_lock.status == "locked" + assert package.references.bible_version == "1.0" + assert all(copy_.disclaimer.strip() for copy_ in package.fact_card.localized) + assert package.fact_card.placement == "append_after_shots" + + +# --------------------------------------------------------------------- # +# Step 4.5 审计新增:references 政策(规范为「镜头实际使用的角色」) +# --------------------------------------------------------------------- # +def test_partial_references_valid_when_unused_character_lacks_reference() -> None: + """只声明、未出场的角色无需参考资产(规范只要求"每个镜头使用到的角色"可解析)。""" + data = _v11() + data["characters"].append( + { + "character_key": "fox_cameo", + "display_name": "Unused Cameo", + "role": "", + "description": "declared but never used in any shot", + "actor_key": None, + "costume_key": None, + "voice_profile": None, + "continuity_notes": "", + } + ) + result = _validate(data, ValidationStage.provider_input) + assert "missing_character_reference" not in {i.code for i in result.errors} + assert result.ok, [(i.code, i.field_path) for i in result.errors] + + +def test_used_character_without_reference_fails_provider_input() -> None: + """镜头使用的角色缺少参考资产 → provider_input 失败。""" + data = _v11() + data["references"]["characters"] = [ + item for item in data["references"]["characters"] if item["character_key"] != "milo_cat" + ] + result = _validate(data, ValidationStage.provider_input) + errors = [i for i in result.errors if i.code == "missing_character_reference"] + assert errors and any("milo_cat" in i.message for i in errors) + + +def test_references_absent_imposes_no_reference_requirement() -> None: + """references 未声明 ⇒ 身份不受约束(不得因此失败)。""" + data = _v11() + data.pop("references") + result = _validate(data, ValidationStage.provider_input) + assert "missing_character_reference" not in {i.code for i in result.errors} + + +# --------------------------------------------------------------------- # +# Step 4.5 审计新增:此前缺少直接覆盖的生命周期规则 +# --------------------------------------------------------------------- # +def test_missing_fact_card_language_fails_post_production() -> None: + """必需发布语言缺少 fact card 文案 → post_production 失败。""" + data = _v11() + data["fact_card"]["localized"] = [c for c in data["fact_card"]["localized"] if c["language_tag"] != "zh-Hant"] + result = _validate(data, ValidationStage.post_production) + assert "missing_fact_card_language" in {i.code for i in result.errors} + + +def test_required_overlay_without_timing_is_unplaceable() -> None: + """required=True 的叠加必须可放置(需给出 start/end)。""" + data = _v11() + for overlay in data["post_production"]["overlays"]: + if overlay["overlay_id"] == "ov_disclaimer": + overlay["start_ms"] = None + overlay["end_ms"] = None + result = _validate(data, ValidationStage.post_production) + assert "unplaceable_overlay" in {i.code for i in result.errors} + + +def test_market_fact_parsing_repeated_at_publish() -> None: + """市场事实解析在 publish 阶段(累积)仍然生效。""" + data = _v11() + data["market_data"]["pullback_pct"] = "not-a-number" + result = _validate(data, ValidationStage.publish) + assert "unparseable_percentage" in {i.code for i in result.errors} + + +def test_generated_footage_assertion_mismatch_fails() -> None: + """generated_footage_ms 断言同样受 ±50 ms 约束。""" + data = _v11() + data["output"]["generated_footage_ms"] = 21051 + result = _validate(data, ValidationStage.pre_render_data_lock) + assert "runtime_assertion_mismatch" in {i.code for i in result.errors} + data["output"]["generated_footage_ms"] = 21050 + assert "runtime_assertion_mismatch" not in {i.code for i in _validate(data, ValidationStage.pre_render_data_lock).errors} + + +def test_cue_shot_association_tolerance_boundaries() -> None: + """cue 与关联镜头窗口的 ±150 ms 容差:边界通过、越界失败。""" + data = _v11() + cue = data["localization"]["subtitle_tracks"][0]["cues"][0] # SC01 窗口 [0,3000] + cue["end_ms"] = 3000 + SHOT_ASSOCIATION_TOLERANCE_MS + assert "shot_association_mismatch" not in { + i.code for i in _validate(data, ValidationStage.pre_render_data_lock).errors + } + cue["end_ms"] = 3000 + SHOT_ASSOCIATION_TOLERANCE_MS + 1 + assert "shot_association_mismatch" in { + i.code for i in _validate(data, ValidationStage.pre_render_data_lock).errors + } + + +def test_duplicate_ids_fail_design() -> None: + """cue_id / overlay_id / 字幕轨语言重复 → design 失败。""" + data = _v11() + data["localization"]["subtitle_tracks"][0]["cues"][1]["cue_id"] = "c1" + assert "duplicate_cue_id" in {i.code for i in _validate(data, ValidationStage.design).errors} + + data = _v11() + data["post_production"]["overlays"][1]["overlay_id"] = "ov_chart_label_01" + assert "duplicate_overlay_id" in {i.code for i in _validate(data, ValidationStage.design).errors} + + data = _v11() + track = copy.deepcopy(data["localization"]["subtitle_tracks"][0]) + data["localization"]["subtitle_tracks"].append(track) + assert "duplicate_subtitle_track" in {i.code for i in _validate(data, ValidationStage.design).errors} + + +def test_unknown_reference_key_and_bad_asset_path_fail_design() -> None: + """参考资产键必须可解析;path 必须是仓库相对路径。""" + data = _v11() + data["references"]["characters"][0]["character_key"] = "ghost" + assert "unknown_reference_key" in {i.code for i in _validate(data, ValidationStage.design).errors} + + data = _v11() + data["references"]["characters"][0]["path"] = "/etc/passwd" + assert "invalid_asset_path" in {i.code for i in _validate(data, ValidationStage.design).errors} + + data = _v11() + data["references"]["characters"][0]["path"] = "../secrets/key.pem" + assert "invalid_asset_path" in {i.code for i in _validate(data, ValidationStage.design).errors} + + +def test_missing_reference_group_key_fails_design() -> None: + """environments 组必须给出 scene_key。""" + data = _v11() + data["references"]["environments"][0]["scene_key"] = None + assert "missing_reference_key" in {i.code for i in _validate(data, ValidationStage.design).errors} + + +def test_fact_card_as_shot_guard() -> None: + """fact card 不得以镜头形式出现。""" + data = _v11() + data["shots"][3]["shot_id"] = "fact_card" + data["localization"]["subtitle_tracks"][0]["cues"][3]["shot_id"] = "fact_card" + data["post_production"]["overlays"][2]["shot_id"] = "fact_card" + assert "fact_card_as_shot" in {i.code for i in _validate(data, ValidationStage.design).errors} + + +def test_invalid_aspect_ratio_fails_design() -> None: + data = _v11() + data["output"]["aspect_ratio"] = "vertical" + assert "invalid_aspect_ratio" in {i.code for i in _validate(data, ValidationStage.design).errors} + + +def test_publish_disclaimer_enforcement_when_blank_is_impossible_by_schema() -> None: + """免责声明结构上非空(min_length=1);空白字符串在 schema 层即被拒绝。""" + data = _v11() + data["fact_card"]["localized"][0]["disclaimer"] = "" + with pytest.raises(ValidationError): + parse_episode_package(data) + + +def test_cli_routes_through_version_dispatch() -> None: + """CLI 的显式解析已走版本分派(v1 行为不变,同时可读 v1.1)。""" + from app.crypto_animal_studio.production import cli as production_cli + + source = Path(production_cli.__file__).read_text(encoding="utf-8") + assert "parse_episode_package(" in source + assert "EpisodePackage.model_validate(" not in source diff --git a/backend/tests/test_cas_production.py b/backend/tests/test_cas_production.py new file mode 100644 index 00000000..dbb6b5b1 --- /dev/null +++ b/backend/tests/test_cas_production.py @@ -0,0 +1,349 @@ +"""CAS 生产流水线测试:模型、提示词、产物路径/校验和、Mock 供应商、编排、失败、重试、manifest。 + +全部离线、确定性;使用内存 SQLite 与临时存储根。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core.db import Base +from app.crypto_animal_studio.production.artifact_manager import ArtifactManager, file_checksum, sanitize_segment +from app.crypto_animal_studio.production.enums import ArtifactType, JobStatus, Stage, STAGE_ORDER, stage_index +from app.crypto_animal_studio.production.models import CasProductionArtifact, CasProductionJob, CasProductionShot +from app.crypto_animal_studio.production.orchestrator import retry_production, start_production +from app.crypto_animal_studio.production.prompt_builder import build_all_prompts, build_shot_prompts +from app.crypto_animal_studio.production.providers.mock import build_mock_bundle +from app.crypto_animal_studio.schemas.episode_package import EpisodePackage + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SAMPLE = _REPO_ROOT / "samples" / "cas" / "demo_episode.json" + + +def _package() -> EpisodePackage: + return EpisodePackage.model_validate(json.loads(_SAMPLE.read_text(encoding="utf-8"))) + + +async def _make_session(): + engine = create_async_engine("sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + import app.crypto_animal_studio.production.models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +async def _count(Session, model) -> int: + async with Session() as db: + return int((await db.execute(select(func.count()).select_from(model))).scalar() or 0) + + +# --------------------------------------------------------------------- # +# 提示词(确定性) +# --------------------------------------------------------------------- # +def test_prompt_builder_is_deterministic() -> None: + """同一 EpisodePackage 恒等产出相同提示词。""" + a = [p.to_dict() for p in build_all_prompts(_package())] + b = [p.to_dict() for p in build_all_prompts(_package())] + assert a == b + + +def test_prompt_builder_fields_present_and_ordered() -> None: + """每镜生成 5 类提示词,且按 sequence 升序。""" + prompts = build_all_prompts(_package()) + assert [p.sequence for p in prompts] == sorted(p.sequence for p in prompts) + first = prompts[0] + assert first.image_prompt and first.negative_prompt and first.video_prompt + assert first.voice_text and first.subtitle_text + assert "camera:" in first.image_prompt + + +def test_prompt_builder_uses_shot_content() -> None: + """提示词包含镜头动作与对白文本(不调用 LLM,纯拼装)。""" + pkg = _package() + shot = sorted(pkg.shots, key=lambda s: s.sequence)[0] + prompts = build_shot_prompts(pkg, shot) + assert shot.action in prompts.image_prompt + assert shot.dialogue[0].text in prompts.subtitle_text + + +# --------------------------------------------------------------------- # +# 路径 / 校验和 +# --------------------------------------------------------------------- # +def test_artifact_paths_follow_convention(tmp_path: Path) -> None: + """产物路径遵循 storage/cas/productions/{project}/{episode}/{job}/... 约定。""" + + async def _run(): + engine, Session = await _make_session() + async with Session() as db: + job = CasProductionJob(id="job-1", project_id="proj-1", episode_id="CAS-E001") + db.add(job) + await db.flush() + m = ArtifactManager(db, job, storage_root=tmp_path) + assert m.job_relpath == "cas/productions/proj-1/CAS-E001/job-1" + assert m.artifact_relpath(ArtifactType.manifest).endswith("/manifest.json") + assert m.artifact_relpath(ArtifactType.final_video).endswith("/final/final_video.txt") + assert m.artifact_relpath(ArtifactType.image, sequence=1, shot_id="SC01").endswith("/shots/1-SC01/image/image.txt") + assert m.artifact_relpath(ArtifactType.prompt, sequence=2, shot_id="SC02").endswith("/shots/2-SC02/prompt.json") + await engine.dispose() + + asyncio.run(_run()) + + +def test_sanitize_segment_blocks_traversal() -> None: + """路径片段清洗可防止穿越与非法字符。""" + assert "/" not in sanitize_segment("../../etc/passwd") + assert sanitize_segment(" ") == "unnamed" + + +def test_file_checksum_matches_hashlib(tmp_path: Path) -> None: + """校验和为文件内容的 SHA-256。""" + import hashlib + + p = tmp_path / "x.txt" + p.write_text("hello", encoding="utf-8") + assert file_checksum(p) == hashlib.sha256(b"hello").hexdigest() + + +# --------------------------------------------------------------------- # +# Mock 供应商真正写文件 +# --------------------------------------------------------------------- # +def test_mock_providers_create_real_deterministic_files(tmp_path: Path) -> None: + """Mock 供应商写出真实文件,且内容确定(两次一致)。""" + bundle = build_mock_bundle() + img = tmp_path / "a" / "image.txt" + r1 = bundle.image.generate_image(target_path=img, prompt="p", negative_prompt="n", context={"shot_id": "SC01", "sequence": 1}) + assert img.is_file() and r1.provider == "mock-image" + first = img.read_bytes() + bundle.image.generate_image(target_path=img, prompt="p", negative_prompt="n", context={"shot_id": "SC01", "sequence": 1}) + assert img.read_bytes() == first + + vid = tmp_path / "a" / "video.txt" + bundle.video.generate_video(target_path=vid, prompt="v", context={"shot_id": "SC01", "sequence": 1, "duration_seconds": 8}) + voice = tmp_path / "a" / "voice.txt" + bundle.voice.generate_voice(target_path=voice, text="hi", context={"shot_id": "SC01", "sequence": 1}) + final = tmp_path / "final" / "final_video.txt" + bundle.composer.compose(target_path=final, shot_inputs=[{"sequence": 1, "shot_id": "SC01"}], context={"episode_id": "E"}) + assert vid.is_file() and voice.is_file() and final.is_file() + + +# --------------------------------------------------------------------- # +# 全流程编排 +# --------------------------------------------------------------------- # +def test_full_pipeline_completes_and_creates_artifacts(tmp_path: Path) -> None: + """有效 EpisodePackage 走完全部阶段,产出各类产物、manifest 与成片。""" + + async def _run(): + engine, Session = await _make_session() + pkg = _package() + async with Session() as db: + job = await start_production(db, project_id="demo-project", package=pkg, providers=build_mock_bundle(), storage_root=tmp_path) + await db.commit() + + assert job.status == JobStatus.completed.value + assert job.current_stage == Stage.finalize.value + assert job.started_at is not None and job.completed_at is not None + + # 每镜 5 类产物 + 任务级 manifest/final_video + n_shots = len(pkg.shots) + assert await _count(Session, CasProductionShot) == n_shots + async with Session() as db: + rows = list((await db.execute(select(CasProductionArtifact))).scalars().all()) + by_type: dict[str, int] = {} + for r in rows: + by_type[r.artifact_type] = by_type.get(r.artifact_type, 0) + 1 + for t in ("prompt", "image", "video", "voice", "subtitle"): + assert by_type[t] == n_shots, f"{t}={by_type.get(t)}" + assert by_type["manifest"] == 1 and by_type["final_video"] == 1 + + # DB 状态与文件系统一致(存在且校验和匹配) + for r in rows: + path = tmp_path / Path(r.file_path) + assert path.is_file(), r.file_path + assert file_checksum(path) == r.checksum + await engine.dispose() + + asyncio.run(_run()) + + +def test_manifest_contents(tmp_path: Path) -> None: + """manifest 包含全部可追溯字段。""" + + async def _run(): + engine, Session = await _make_session() + pkg = _package() + async with Session() as db: + job = await start_production(db, project_id="demo-project", package=pkg, providers=build_mock_bundle(), storage_root=tmp_path) + await db.commit() + manifest_path = tmp_path / Path(f"cas/productions/demo-project/{pkg.episode_id}/{job.id}/manifest.json") + data = json.loads(manifest_path.read_text(encoding="utf-8")) + for key in ( + "job_id", + "project_id", + "episode_id", + "status", + "episode_package_hash", + "started_at", + "completed_at", + "shots", + "artifacts", + "providers", + "errors", + "final_output", + ): + assert key in data, key + assert data["status"] == "completed" + assert len(data["shots"]) == len(pkg.shots) + assert data["final_output"].endswith("final/final_video.txt") + assert data["providers"]["image"]["provider"] == "mock-image" + assert data["errors"] == [] + await engine.dispose() + + asyncio.run(_run()) + + +def test_new_run_creates_new_job(tmp_path: Path) -> None: + """再次运行同一 package 会创建新的任务(不复用旧 job)。""" + + async def _run(): + engine, Session = await _make_session() + pkg = _package() + async with Session() as db: + j1 = await start_production(db, project_id="p", package=pkg, providers=build_mock_bundle(), storage_root=tmp_path) + await db.commit() + async with Session() as db: + j2 = await start_production(db, project_id="p", package=pkg, providers=build_mock_bundle(), storage_root=tmp_path) + await db.commit() + assert j1.id != j2.id + assert await _count(Session, CasProductionJob) == 2 + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------- # +# 失败与重试 +# --------------------------------------------------------------------- # +def test_forced_failure_marks_job_and_shot_failed(tmp_path: Path) -> None: + """强制 mock 失败:任务与对应镜头标记 failed,已成功产物保留。""" + + async def _run(): + engine, Session = await _make_session() + pkg = _package() + async with Session() as db: + job = await start_production( + db, project_id="p", package=pkg, providers=build_mock_bundle(video_fail_on_sequence=2), storage_root=tmp_path + ) + await db.commit() + + assert job.status == JobStatus.failed.value + assert job.current_stage == Stage.video_generation.value + assert "mock video failure" in job.error_message + + async with Session() as db: + shots = list((await db.execute(select(CasProductionShot).order_by(CasProductionShot.sequence))).scalars().all()) + failed = [s for s in shots if s.status == JobStatus.failed.value] + assert len(failed) == 1 and failed[0].sequence == 2 + arts = list((await db.execute(select(CasProductionArtifact))).scalars().all()) + # 更早阶段(prompt/image)的产物被保留 + kinds = {a.artifact_type for a in arts} + assert "prompt" in kinds and "image" in kinds + assert all((tmp_path / Path(a.file_path)).is_file() for a in arts) + await engine.dispose() + + asyncio.run(_run()) + + +def test_retry_reuses_earlier_artifacts_and_completes(tmp_path: Path) -> None: + """重试从失败阶段开始:更早产物被复用(内容与校验和不变),最终完成。""" + + async def _run(): + engine, Session = await _make_session() + pkg = _package() + async with Session() as db: + job = await start_production( + db, project_id="p", package=pkg, providers=build_mock_bundle(video_fail_on_sequence=2), storage_root=tmp_path + ) + await db.commit() + assert job.status == JobStatus.failed.value + + # 记录失败前 image 产物的校验和与文件修改时间 + async with Session() as db: + images = list( + (await db.execute(select(CasProductionArtifact).where(CasProductionArtifact.artifact_type == "image"))).scalars().all() + ) + before = {a.id: (a.checksum, (tmp_path / Path(a.file_path)).stat().st_mtime_ns) for a in images} + + async with Session() as db: + retried = await retry_production(db, job_id=job.id, package=pkg, providers=build_mock_bundle(), storage_root=tmp_path) + await db.commit() + + assert retried.status == JobStatus.completed.value + assert retried.id == job.id # 同一任务续跑 + + async with Session() as db: + images_after = list( + (await db.execute(select(CasProductionArtifact).where(CasProductionArtifact.artifact_type == "image"))).scalars().all() + ) + # 未重新生成:校验和一致且文件未被改写 + assert len(images_after) == len(before) + for a in images_after: + checksum, mtime = before[a.id] + assert a.checksum == checksum + assert (tmp_path / Path(a.file_path)).stat().st_mtime_ns == mtime + + # 失败阶段及之后的产物齐全 + async with Session() as db: + arts = list((await db.execute(select(CasProductionArtifact))).scalars().all()) + by_type: dict[str, int] = {} + for a in arts: + by_type[a.artifact_type] = by_type.get(a.artifact_type, 0) + 1 + n = len(pkg.shots) + assert by_type["video"] == n and by_type["voice"] == n and by_type["subtitle"] == n + assert by_type["final_video"] == 1 + await engine.dispose() + + asyncio.run(_run()) + + +def test_retry_rejects_mismatched_package(tmp_path: Path) -> None: + """重试时 package 与原任务不一致 → 任务标记失败并记录 PackageMismatch。""" + + async def _run(): + engine, Session = await _make_session() + pkg = _package() + async with Session() as db: + job = await start_production( + db, project_id="p", package=pkg, providers=build_mock_bundle(video_fail_on_sequence=2), storage_root=tmp_path + ) + await db.commit() + changed = json.loads(_SAMPLE.read_text(encoding="utf-8")) + changed["title"] = "Different" + async with Session() as db: + result = await retry_production( + db, job_id=job.id, package=EpisodePackage.model_validate(changed), providers=build_mock_bundle(), storage_root=tmp_path + ) + await db.commit() + assert result.status == JobStatus.failed.value + assert "PackageMismatch" in result.error_message or "does not match" in result.error_message + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------- # +# 枚举与阶段顺序 +# --------------------------------------------------------------------- # +def test_stage_order_and_index() -> None: + """阶段顺序符合流水线定义。""" + assert STAGE_ORDER[0] is Stage.validate and STAGE_ORDER[-1] is Stage.finalize + assert stage_index(Stage.image_generation) < stage_index(Stage.composition) + assert {s.value for s in JobStatus} == {"pending", "running", "completed", "failed", "cancelled"} + assert {a.value for a in ArtifactType} >= {"prompt", "image", "video", "voice", "subtitle", "manifest", "final_video"} diff --git a/backend/tests/test_cas_production_api_cli.py b/backend/tests/test_cas_production_api_cli.py new file mode 100644 index 00000000..7ce5c000 --- /dev/null +++ b/backend/tests/test_cas_production_api_cli.py @@ -0,0 +1,180 @@ +"""CAS 生产 API 与 CLI 测试(离线、确定性)。 + +API 使用最小 FastAPI app 挂载 CAS 路由并覆盖 get_db(内存 SQLite),避免拉起完整应用。 +CLI 直接调用 ``main()``,用 --storage-root/--create-tables 指向临时目录与 SQLite 文件。 +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator, Iterator +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +import app.crypto_animal_studio.api.production as production_route +from app.core.db import Base +from app.crypto_animal_studio.api import router as cas_router +from app.crypto_animal_studio.production.providers.mock import build_mock_bundle +from app.dependencies import get_db + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_SAMPLE = _REPO_ROOT / "samples" / "cas" / "demo_episode.json" + + +def _package_dict() -> dict: + return json.loads(_SAMPLE.read_text(encoding="utf-8")) + + +@pytest.fixture() +def api(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[TestClient, Path]]: + """构造挂载 CAS 路由的最小 app,并把存储根指向 tmp_path。 + + 生命周期纪律:建表与 engine.dispose() 都在 app lifespan 内完成,并以上下文管理器方式 + 使用 TestClient,确保所有 aiosqlite 连接在同一个事件循环中创建与释放,避免 + worker 线程在事件循环关闭后回调(PytestUnhandledThreadExceptionWarning)。 + """ + engine = create_async_engine("sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + import app.crypto_animal_studio.production.models # noqa: F401 + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async def _get_db() -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + @asynccontextmanager + async def _lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + await engine.dispose() + + # 让路由内部创建的编排使用临时存储根 + monkeypatch.setenv("CAS_STORAGE_ROOT", str(tmp_path)) + + app = FastAPI(lifespan=_lifespan) + app.include_router(cas_router, prefix="/api/v1/crypto-animal-studio") + app.dependency_overrides[get_db] = _get_db + with TestClient(app) as test_client: + yield test_client, tmp_path + + +def test_create_job_endpoint_runs_pipeline(api) -> None: + """POST /production/jobs 创建任务并同步跑完,返回 ApiResponse 壳。""" + client, storage = api + resp = client.post( + "/api/v1/crypto-animal-studio/production/jobs", + json={"project_id": "demo-project", "episode_package": _package_dict(), "mode": "mock"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert {"code", "message", "data"}.issubset(body.keys()) + data = body["data"] + assert data["status"] == "completed" + assert data["manifest_path"].endswith("manifest.json") + assert data["final_output"].endswith("final/final_video.txt") + assert len(data["shots"]) == len(_package_dict()["shots"]) + assert (storage / Path(data["manifest_path"])).is_file() + + +def test_get_job_and_artifacts_endpoints(api) -> None: + """GET 任务与产物列表可用;未知任务返回 404。""" + client, _ = api + created = client.post( + "/api/v1/crypto-animal-studio/production/jobs", + json={"project_id": "demo-project", "episode_package": _package_dict(), "mode": "mock"}, + ).json()["data"] + + got = client.get(f"/api/v1/crypto-animal-studio/production/jobs/{created['id']}") + assert got.status_code == 200 + assert got.json()["data"]["id"] == created["id"] + + arts = client.get(f"/api/v1/crypto-animal-studio/production/jobs/{created['id']}/artifacts") + assert arts.status_code == 200 + items = arts.json()["data"] + kinds = {a["artifact_type"] for a in items} + assert {"prompt", "image", "video", "voice", "subtitle", "manifest", "final_video"}.issubset(kinds) + assert all(a["checksum"] for a in items) + + assert client.get("/api/v1/crypto-animal-studio/production/jobs/missing").status_code == 404 + assert client.get("/api/v1/crypto-animal-studio/production/jobs/missing/artifacts").status_code == 404 + + +def test_retry_endpoint(api, monkeypatch: pytest.MonkeyPatch) -> None: + """POST retry:失败任务重试后完成;未知任务 404。""" + client, _ = api + # 先制造一次失败(video 第 2 镜) + monkeypatch.setattr(production_route, "build_mock_bundle", lambda: build_mock_bundle(video_fail_on_sequence=2)) + failed = client.post( + "/api/v1/crypto-animal-studio/production/jobs", + json={"project_id": "demo-project", "episode_package": _package_dict(), "mode": "mock"}, + ).json()["data"] + assert failed["status"] == "failed" + + # 恢复正常供应商后重试 + monkeypatch.setattr(production_route, "build_mock_bundle", lambda: build_mock_bundle()) + retried = client.post( + f"/api/v1/crypto-animal-studio/production/jobs/{failed['id']}/retry", + json={"episode_package": _package_dict(), "mode": "mock"}, + ) + assert retried.status_code == 200 + assert retried.json()["data"]["status"] == "completed" + + assert ( + client.post( + "/api/v1/crypto-animal-studio/production/jobs/missing/retry", + json={"episode_package": _package_dict(), "mode": "mock"}, + ).status_code + == 404 + ) + + +def test_create_job_rejects_unknown_field(api) -> None: + """请求体未知字段被拒绝(extra=forbid)。""" + client, _ = api + resp = client.post( + "/api/v1/crypto-animal-studio/production/jobs", + json={"project_id": "p", "episode_package": _package_dict(), "mode": "mock", "surprise": 1}, + ) + assert resp.status_code == 422 + + +def test_cli_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None: + """CLI run 子命令跑通并打印状态/job id/manifest/final output。""" + from app.config import settings + from app.crypto_animal_studio.production import cli + + db_file = tmp_path / "cli.db" + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{db_file.as_posix()}") + + code = cli.main( + [ + "run", + "--project-id", + "demo-project", + "--episode-package", + str(_SAMPLE), + "--provider-mode", + "mock", + "--storage-root", + str(tmp_path / "storage"), + "--create-tables", + ] + ) + out = capsys.readouterr().out + assert code == 0 + assert "status: completed" in out + assert "job_id: " in out + assert "manifest: " in out and "manifest.json" in out + assert "final_output: " in out and "final_video.txt" in out diff --git a/backend/tests/test_cas_subtitle_artifact.py b/backend/tests/test_cas_subtitle_artifact.py new file mode 100644 index 00000000..fd79d323 --- /dev/null +++ b/backend/tests/test_cas_subtitle_artifact.py @@ -0,0 +1,514 @@ +"""Step 5.1:字幕产物与真实 worker 执行的硬化测试。 + +覆盖: +- 确定性 zh-Hant WebVTT 生成(逐字节稳定); +- cue 时间戳与译文逐字保真; +- 产物与 Project / Chapter 的关联; +- 二次导入不产生重复产物(对象与数据库行都不重复); +- 上传失败后的回滚与补偿清理(无孤儿对象、无部分数据库记录); +- worker 注册、入队、成功、失败; +- 活动任务复用; +- 任务结果中包含产物信息。 +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core import storage as core_storage +from app.core.db import Base, async_session_maker +from app.core.task_manager.types import TaskStatus +from app.crypto_animal_studio.application.import_episode import import_episode +from app.crypto_animal_studio.application.import_result import ImportResult, SubtitleArtifact +from app.crypto_animal_studio.application.import_tasks import ( + CAS_IMPORT_EPISODE_TASK_KIND, + _compensate_uploaded_artifacts, + create_cas_import_task, + run_cas_import_task, +) +from app.crypto_animal_studio.application.parsing import parse_episode_package +from app.crypto_animal_studio.application.subtitle_artifact import ( + subtitle_source_ref, + subtitle_storage_key, +) +from app.crypto_animal_studio.domain.webvtt import format_timestamp, render_webvtt +from app.crypto_animal_studio.domain.import_ledger import CasImportLedger +from app.models.studio import Chapter, Project, Shot +from app.models.studio_file_usages import FileUsage +from app.models.studio_prompts_files_timeline import FileItem +from app.models.task import GenerationTask +from app.models.types import FileType, FileUsageKind, ProjectStyle, ProjectVisualStyle +from app.services.worker.task_registry import task_executor_registry +from tests.support.fake_storage import FakeStorage + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_EP001 = _REPO_ROOT / "samples" / "cas" / "ep001_btc_breakout.json" +_V1_SAMPLE = _REPO_ROOT / "samples" / "cas" / "demo_episode.json" + +_EXPECTED_ZH_HANT = [ + "突破了!我們回來了!", + "這根K棒還沒收。", + "還是綠的……對吧?", + "你的彩帶會比收盤先到。", +] + + +@pytest.fixture(autouse=True) +def fake_storage(monkeypatch: pytest.MonkeyPatch) -> FakeStorage: + """内存对象存储替身。""" + return FakeStorage().install(monkeypatch, core_storage) + + +def _ep001_dict() -> dict: + return json.loads(_EP001.read_text(encoding="utf-8")) + + +def _ep001_package(): + return parse_episode_package(_ep001_dict()) + + +async def _make_sessionmaker(): + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.crypto_animal_studio.domain.import_ledger # noqa: F401 + import app.models.llm # noqa: F401 + import app.models.studio # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +async def _seed_project(session_factory, project_id: str = "cas-series-1") -> str: + async with session_factory() as db: + db.add( + Project( + id=project_id, + name="Crypto Animal Studio — Block Street (Season 1)", + style=ProjectStyle.anime_3d, + visual_style=ProjectVisualStyle.anime, + ) + ) + await db.commit() + return project_id + + +async def _count(session_factory, model) -> int: + async with session_factory() as db: + return int((await db.execute(select(func.count()).select_from(model))).scalar() or 0) + + +# --------------------------------------------------------------------------- # +# 1. 确定性 WebVTT +# --------------------------------------------------------------------------- # +def test_webvtt_timestamp_formatting() -> None: + """毫秒 → HH:MM:SS.mmm。""" + assert format_timestamp(0) == "00:00:00.000" + assert format_timestamp(400) == "00:00:00.400" + assert format_timestamp(24_000) == "00:00:24.000" + assert format_timestamp(3_661_123) == "01:01:01.123" + with pytest.raises(ValueError): + format_timestamp(-1) + + +def test_webvtt_generation_is_deterministic() -> None: + """同一 track 渲染两次逐字节相同。""" + track = _ep001_package().localization.subtitle_tracks[0] + first = render_webvtt(track) + second = render_webvtt(_ep001_package().localization.subtitle_tracks[0]) + assert first == second + assert first.encode("utf-8") == second.encode("utf-8") + + +def test_webvtt_preserves_every_required_field() -> None: + """语言标签、cue ID、顺序、起止时间、译文、镜头引用全部保留。""" + package = _ep001_package() + track = package.localization.subtitle_tracks[0] + text = render_webvtt(track) + + assert text.startswith("WEBVTT\nLanguage: zh-Hant\n") + assert text.endswith("\n") + + for cue in track.cues: + assert f"\n{cue.cue_id}\n" in text + assert f"{format_timestamp(cue.start_ms)} --> {format_timestamp(cue.end_ms)}" in text + assert cue.text in text + assert f"shot={cue.shot_id}" in text + + # cue 顺序保持声明顺序 + positions = [text.index(cue.text) for cue in track.cues] + assert positions == sorted(positions) + # 译文逐字保真 + assert [cue.text for cue in track.cues] == _EXPECTED_ZH_HANT + + +def test_webvtt_rejects_invalid_cue_window() -> None: + """end_ms <= start_ms 直接拒绝(不生成半成品产物)。""" + data = _ep001_dict() + # 绕过 schema 校验直接构造轨对象,验证渲染层自身的防线。 + track = parse_episode_package(data).localization.subtitle_tracks[0] + track.cues[0].end_ms = track.cues[0].start_ms + with pytest.raises(ValueError, match="end_ms"): + render_webvtt(track) + + +# --------------------------------------------------------------------------- # +# 2. 产物关联 +# --------------------------------------------------------------------------- # +def test_subtitle_artifact_is_linked_to_project_and_chapter(fake_storage: FakeStorage) -> None: + """产物落成 FileItem + FileUsage,并关联到 Project 与 Chapter。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + result = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="k1" + ) + await db.commit() + + # 导入结果显式标识产物(要求 8) + assert len(result.subtitle_artifacts) == 1 + artifact = result.subtitle_artifacts[0] + assert artifact.language_tag == "zh-Hant" + assert artifact.cue_count == 4 + assert artifact.created is True + assert artifact.storage_key == subtitle_storage_key(pid, "CAS-EP001", "zh-Hant") + + async with session_factory() as db: + chapter = (await db.execute(select(Chapter))).scalars().one() + file_item = (await db.execute(select(FileItem))).scalars().one() + usage = (await db.execute(select(FileUsage))).scalars().one() + + assert file_item.id == artifact.file_id + assert file_item.type == FileType.subtitle + assert file_item.name == "CAS-EP001.zh-Hant.vtt" + assert file_item.storage_key == artifact.storage_key + assert usage.file_id == file_item.id + assert usage.project_id == pid + assert usage.chapter_id == chapter.id + assert usage.usage_kind == FileUsageKind.subtitle + assert usage.source_ref == subtitle_source_ref("CAS-EP001", "zh-Hant") + + # 对象内容就是确定性 WebVTT + stored = fake_storage.objects[artifact.storage_key].decode("utf-8") + assert stored == render_webvtt(_ep001_package().localization.subtitle_tracks[0]) + assert len(stored.encode("utf-8")) == artifact.byte_size + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_v1_package_produces_no_subtitle_artifact() -> None: + """v1 文档没有 localization → 不生成产物,既有行为完全不变。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + package = parse_episode_package(json.loads(_V1_SAMPLE.read_text(encoding="utf-8"))) + async with session_factory() as db: + result = await import_episode( + db, project_id=pid, package=package, idempotency_key="v1" + ) + await db.commit() + assert result.subtitle_artifacts == [] + assert await _count(session_factory, FileItem) == 0 + assert await _count(session_factory, FileUsage) == 0 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- # +# 3. 幂等 +# --------------------------------------------------------------------------- # +def test_second_import_does_not_duplicate_artifact(fake_storage: FakeStorage) -> None: + """幂等重放不新增 FileItem / FileUsage / 对象,并如实报告既有产物。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + first = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="k1" + ) + await db.commit() + async with session_factory() as db: + second = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="k1" + ) + await db.commit() + + assert second.status == "replayed" + assert await _count(session_factory, FileItem) == 1 + assert await _count(session_factory, FileUsage) == 1 + assert len(fake_storage.objects) == 1 + # 重放仍然报告产物,且指向同一个 file_id + assert len(second.subtitle_artifacts) == 1 + assert second.subtitle_artifacts[0].file_id == first.subtitle_artifacts[0].file_id + assert second.subtitle_artifacts[0].created is False + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_reimport_under_new_key_reuses_same_artifact_slot(fake_storage: FakeStorage) -> None: + """同一剧集在新项目章节下重新导入时,产物按确定性键覆盖而非新增。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + async with session_factory() as db: + await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="k1" + ) + await db.commit() + + # 清掉台账模拟「重新导入同一剧集」,产物槽位必须复用而不是新增。 + async with session_factory() as db: + for row in (await db.execute(select(CasImportLedger))).scalars().all(): + await db.delete(row) + await db.commit() + + async with session_factory() as db: + again = await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="k2" + ) + await db.commit() + + assert again.status == "imported" + assert again.subtitle_artifacts[0].created is False + assert await _count(session_factory, FileItem) == 1 + assert await _count(session_factory, FileUsage) == 1 + assert len(fake_storage.objects) == 1 + # 新章节接管关联 + async with session_factory() as db: + usage = (await db.execute(select(FileUsage))).scalars().one() + assert usage.chapter_id == again.chapter_id + finally: + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- # +# 4. 失败与补偿 +# --------------------------------------------------------------------------- # +def test_upload_failure_rolls_back_and_leaves_no_orphans(fake_storage: FakeStorage) -> None: + """上传失败 → 整个导入回滚:无数据库记录、无孤儿对象。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + pid = await _seed_project(session_factory) + fake_storage.fail_upload_key = subtitle_storage_key(pid, "CAS-EP001", "zh-Hant") + + async with session_factory() as db: + with pytest.raises(Exception, match="injected upload failure"): + await import_episode( + db, project_id=pid, package=_ep001_package(), idempotency_key="k1" + ) + await db.rollback() + + assert await _count(session_factory, Chapter) == 0 + assert await _count(session_factory, Shot) == 0 + assert await _count(session_factory, FileItem) == 0 + assert await _count(session_factory, FileUsage) == 0 + assert await _count(session_factory, CasImportLedger) == 0 + assert fake_storage.objects == {} + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_compensation_deletes_only_newly_created_objects(fake_storage: FakeStorage) -> None: + """提交失败后的补偿只删除本次新建的对象,不动复用的既有产物。 + + 这是「对象存储不参与数据库事务」的兜底路径:``import_episode`` 已经上传成功, + 但调用方的 commit 失败,此时必须回收本次新建的对象,同时保留上一次成功导入的产物。 + """ + + async def _run() -> None: + fresh = SubtitleArtifact( + file_id="f-new", + language_tag="zh-Hant", + storage_key="cas/subtitles/p/E1/zh-Hant.vtt", + cue_count=4, + byte_size=10, + created=True, + ) + reused = SubtitleArtifact( + file_id="f-old", + language_tag="en", + storage_key="cas/subtitles/p/E1/en.vtt", + cue_count=4, + byte_size=10, + created=False, + ) + fake_storage.objects[fresh.storage_key] = b"new" + fake_storage.objects[reused.storage_key] = b"old" + + result = ImportResult( + status="imported", + dry_run=False, + idempotent_replay=False, + project_id="p", + episode_id="E1", + idempotency_key="k", + payload_hash="h", + subtitle_artifacts=[fresh, reused], + ) + await _compensate_uploaded_artifacts(result) + + assert fake_storage.delete_calls == [fresh.storage_key] + assert fresh.storage_key not in fake_storage.objects + assert reused.storage_key in fake_storage.objects # 复用的产物必须保留 + + asyncio.run(_run()) + + +def test_compensation_is_a_noop_without_result(fake_storage: FakeStorage) -> None: + """导入尚未返回结果(例如解析阶段就失败)时补偿不应做任何事。""" + asyncio.run(_compensate_uploaded_artifacts(None)) + assert fake_storage.delete_calls == [] + + +# --------------------------------------------------------------------------- # +# 5. Worker 注册与执行 +# --------------------------------------------------------------------------- # +def test_worker_executor_is_registered() -> None: + """task_kind 已注册到既有 registry,且未新建队列体系。""" + executor = task_executor_registry.resolve(CAS_IMPORT_EPISODE_TASK_KIND) + assert executor.task_kind == CAS_IMPORT_EPISODE_TASK_KIND + assert executor.timeout_seconds == 300.0 + + +def test_worker_success_persists_result_with_artifact(fake_storage: FakeStorage) -> None: + """worker 成功:导入落库,任务结果里带字幕产物信息。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + original = async_session_maker._maker # pylint: disable=protected-access + try: + pid = await _seed_project(session_factory) + async_session_maker.configure(session_factory) + + async with session_factory() as db: + created = await create_cas_import_task( + db, project_id=pid, episode_package=_ep001_dict(), idempotency_key="k1" + ) + await db.commit() + + await run_cas_import_task(created.task_id) + + async with session_factory() as db: + task = await db.get(GenerationTask, created.task_id) + status_value = ( + task.status.value if hasattr(task.status, "value") else str(task.status) + ) + result = task.result or {} + + assert status_value == TaskStatus.succeeded.value + artifacts = result.get("subtitle_artifacts") or [] + assert len(artifacts) == 1 + assert artifacts[0]["language_tag"] == "zh-Hant" + assert artifacts[0]["cue_count"] == 4 + assert artifacts[0]["storage_key"] in fake_storage.objects + assert await _count(session_factory, Chapter) == 1 + assert await _count(session_factory, FileItem) == 1 + finally: + async_session_maker.configure(original) + await engine.dispose() + + asyncio.run(_run()) + + +def test_worker_failure_marks_failed_without_partial_import(fake_storage: FakeStorage) -> None: + """worker 失败:任务 failed,且没有任何部分导入或孤儿对象。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + original = async_session_maker._maker # pylint: disable=protected-access + try: + async_session_maker.configure(session_factory) + async with session_factory() as db: + created = await create_cas_import_task( + db, + project_id="no-such-project", + episode_package=_ep001_dict(), + idempotency_key="k1", + ) + await db.commit() + + await run_cas_import_task(created.task_id) + + async with session_factory() as db: + task = await db.get(GenerationTask, created.task_id) + status_value = ( + task.status.value if hasattr(task.status, "value") else str(task.status) + ) + assert status_value == TaskStatus.failed.value + assert "Project not found" in task.error + assert await _count(session_factory, Chapter) == 0 + assert await _count(session_factory, FileItem) == 0 + assert fake_storage.objects == {} + finally: + async_session_maker.configure(original) + await engine.dispose() + + asyncio.run(_run()) + + +def test_worker_accepts_run_args_from_executor(fake_storage: FakeStorage) -> None: + """runner 签名兼容 (task_id, run_args):executor 传入的 run_args 被直接使用。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + original = async_session_maker._maker # pylint: disable=protected-access + try: + pid = await _seed_project(session_factory) + async_session_maker.configure(session_factory) + async with session_factory() as db: + created = await create_cas_import_task( + db, project_id=pid, episode_package=_ep001_dict(), idempotency_key="k1" + ) + await db.commit() + + run_args = { + "project_id": pid, + "episode_package": _ep001_dict(), + "idempotency_key": "k1", + "dry_run": False, + } + await run_cas_import_task(created.task_id, run_args) + + async with session_factory() as db: + task = await db.get(GenerationTask, created.task_id) + status_value = ( + task.status.value if hasattr(task.status, "value") else str(task.status) + ) + assert status_value == TaskStatus.succeeded.value + assert len(fake_storage.objects) == 1 + finally: + async_session_maker.configure(original) + await engine.dispose() + + asyncio.run(_run()) diff --git a/backend/tests/test_entities_api_responses.py b/backend/tests/test_entities_api_responses.py index 4d73b146..ec28e082 100644 --- a/backend/tests/test_entities_api_responses.py +++ b/backend/tests/test_entities_api_responses.py @@ -103,7 +103,7 @@ def test_get_actor_entity_not_found_returns_api_response(client: TestClient) -> app.dependency_overrides.clear() assert response.status_code == 404 - assert response.json() == {"code": 404, "message": "Actor not found", "data": None} + assert response.json() == {"code": 404, "message": "Actor not found", "data": None, "meta": None} def test_delete_actor_entity_returns_empty_envelope(client: TestClient) -> None: @@ -129,7 +129,7 @@ def test_delete_actor_entity_returns_empty_envelope(client: TestClient) -> None: app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"code": 200, "message": "success", "data": None} + assert response.json() == {"code": 200, "message": "success", "data": None, "meta": None} assert "actor-1" not in db.actors @@ -146,4 +146,5 @@ def test_create_entity_invalid_entity_type_returns_api_response(client: TestClie "code": 400, "message": "entity_type must be one of: actor/character/scene/prop/costume", "data": None, + "meta": None, } diff --git a/backend/tests/test_entity_existence_api_responses.py b/backend/tests/test_entity_existence_api_responses.py index 57875e60..c19d3540 100644 --- a/backend/tests/test_entity_existence_api_responses.py +++ b/backend/tests/test_entity_existence_api_responses.py @@ -77,6 +77,7 @@ async def _fake_check(self, **_kwargs): # noqa: ANN001 "scenes": [], "costumes": [], }, + "meta": None, } @@ -106,4 +107,5 @@ async def _fake_check(self, **_kwargs): # noqa: ANN001 "code": 404, "message": "shot_id does not belong to project_id", "data": None, + "meta": None, } diff --git a/backend/tests/test_files_api_responses.py b/backend/tests/test_files_api_responses.py index 62f5723c..0aa0ccd1 100644 --- a/backend/tests/test_files_api_responses.py +++ b/backend/tests/test_files_api_responses.py @@ -50,8 +50,12 @@ def test_list_files_requires_project_id_when_scope_filters_set(client: TestClien assert response.status_code == 400 assert response.json() == { "code": 400, - "message": "project_id is required when chapter_title or shot_title is set", + "message": ( + "project_id is required when chapter_title, shot_title, " + "chapter_id or usage_kind is set" + ), "data": None, + "meta": None, } @@ -69,7 +73,7 @@ async def _fake_get_file_detail(*_args, **_kwargs): app.dependency_overrides.clear() assert response.status_code == 404 - assert response.json() == {"code": 404, "message": "File not found", "data": None} + assert response.json() == {"code": 404, "message": "File not found", "data": None, "meta": None} def test_delete_file_returns_empty_envelope(client: TestClient, monkeypatch) -> None: @@ -86,7 +90,7 @@ async def _fake_delete_file(*_args, **_kwargs) -> None: app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"code": 200, "message": "success", "data": None} + assert response.json() == {"code": 200, "message": "success", "data": None, "meta": None} def test_update_file_meta_returns_success_envelope(client: TestClient, monkeypatch) -> None: diff --git a/backend/tests/test_files_scope_filters.py b/backend/tests/test_files_scope_filters.py new file mode 100644 index 00000000..5be3ad29 --- /dev/null +++ b/backend/tests/test_files_scope_filters.py @@ -0,0 +1,199 @@ +"""GET /files 的 chapter_id / usage_kind 附加过滤(Step 6 最小检索契约调整)。 + +背景:EP001 工作台需要按章节取出该章节的 zh-Hant 字幕产物。既有实现只支持 +``chapter_title``(标题精确匹配,标题并不唯一)且没有 ``usage_kind`` 过滤, +因此新增两个**可选**查询参数;省略时行为与既有实现完全一致。 +""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core.db import Base +from app.models.studio import Chapter, FileItem, FileUsage, Project +from app.models.types import ( + ChapterStatus, + FileType, + FileUsageKind, + ProjectStyle, + ProjectVisualStyle, +) +from app.services.studio.file_usages import list_files_by_scope_paginated + + +async def _make_sessionmaker(): + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.models.llm # noqa: F401 + import app.models.studio # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +async def _seed(session_factory) -> dict: + """两个同名章节各挂一个字幕文件,外加一个图片文件,用于区分过滤效果。""" + ids: dict = {} + async with session_factory() as db: + project = Project( + id="p1", + name="Series", + style=ProjectStyle.anime_3d, + visual_style=ProjectVisualStyle.anime, + ) + db.add(project) + + # 两个章节标题**相同** —— 正是 chapter_title 无法区分的场景。 + for key in ("a", "b"): + chapter = Chapter( + id=f"ch-{key}", + project_id="p1", + index=1 if key == "a" else 2, + title="Same Title", + summary="", + raw_text="", + condensed_text="", + storyboard_count=0, + status=ChapterStatus.draft, + ) + db.add(chapter) + subtitle = FileItem( + id=f"file-sub-{key}", + type=FileType.subtitle, + name=f"EP-{key}.zh-Hant.vtt", + thumbnail="", + tags=[], + storage_key=f"cas/subtitles/p1/EP-{key}/zh-Hant.vtt", + ) + db.add(subtitle) + db.add( + FileUsage( + file_id=subtitle.id, + project_id="p1", + chapter_id=chapter.id, + shot_id=None, + usage_kind=FileUsageKind.subtitle, + source_ref=f"cas:EP-{key}:zh-Hant", + ) + ) + ids[key] = chapter.id + + # 同章节下的另一类文件:不应被 usage_kind=subtitle 命中。 + image = FileItem( + id="file-img", + type=FileType.image, + name="frame.png", + thumbnail="", + tags=[], + storage_key="files/frame.png", + ) + db.add(image) + db.add( + FileUsage( + file_id=image.id, + project_id="p1", + chapter_id="ch-a", + shot_id=None, + usage_kind=FileUsageKind.shot_frame, + source_ref=str(uuid.uuid4()), + ) + ) + await db.commit() + return ids + + +def test_chapter_id_filter_distinguishes_same_titled_chapters() -> None: + """chapter_id 能区分标题相同的两个章节;chapter_title 做不到。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + by_id, total_by_id = await list_files_by_scope_paginated( + db, project_id="p1", chapter_id="ch-a", usage_kind="subtitle" + ) + by_title, total_by_title = await list_files_by_scope_paginated( + db, project_id="p1", chapter_title="Same Title", usage_kind="subtitle" + ) + + assert total_by_id == 1 + assert [f.id for f in by_id] == ["file-sub-a"] + # 标题相同 → 两个章节的字幕都被命中,无法定位 + assert total_by_title == 2 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_usage_kind_filter_excludes_other_kinds() -> None: + """usage_kind 只返回该用途的文件。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + subs, sub_total = await list_files_by_scope_paginated( + db, project_id="p1", chapter_id="ch-a", usage_kind="subtitle" + ) + frames, frame_total = await list_files_by_scope_paginated( + db, project_id="p1", chapter_id="ch-a", usage_kind="shot_frame" + ) + everything, all_total = await list_files_by_scope_paginated( + db, project_id="p1", chapter_id="ch-a" + ) + + assert [f.id for f in subs] == ["file-sub-a"] and sub_total == 1 + assert [f.id for f in frames] == ["file-img"] and frame_total == 1 + # 不带 usage_kind → 该章节下两类文件都返回(既有行为不变) + assert all_total == 2 + assert {f.id for f in everything} == {"file-sub-a", "file-img"} + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_omitting_new_filters_preserves_existing_behaviour() -> None: + """两个新参数都省略时,结果与既有 project-only 查询一致。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + items, total = await list_files_by_scope_paginated(db, project_id="p1") + assert total == 3 + assert {f.id for f in items} == {"file-sub-a", "file-sub-b", "file-img"} + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_unknown_chapter_id_returns_empty() -> None: + """未知章节返回空集而不是报错。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + items, total = await list_files_by_scope_paginated( + db, project_id="p1", chapter_id="ch-missing" + ) + assert items == [] and total == 0 + finally: + await engine.dispose() + + asyncio.run(_run()) diff --git a/backend/tests/test_shot_character_links_api_responses.py b/backend/tests/test_shot_character_links_api_responses.py index 82820b06..9db6e36b 100644 --- a/backend/tests/test_shot_character_links_api_responses.py +++ b/backend/tests/test_shot_character_links_api_responses.py @@ -113,4 +113,5 @@ async def _fake_upsert(*_args, **_kwargs): "code": 400, "message": "Character does not belong to the same project", "data": None, + "meta": None, } diff --git a/backend/tests/test_shot_subresource_api_responses.py b/backend/tests/test_shot_subresource_api_responses.py index 637e1f90..b3f01905 100644 --- a/backend/tests/test_shot_subresource_api_responses.py +++ b/backend/tests/test_shot_subresource_api_responses.py @@ -29,6 +29,29 @@ ) +class _EmptyScalars: + """``Result.scalars()`` 的最小替身:始终为空。""" + + @staticmethod + def first() -> None: + """返回 None,表示查询无匹配行。""" + return None + + @staticmethod + def all() -> list: + """返回空列表,表示查询无匹配行。""" + return [] + + +class _EmptyResult: + """``AsyncSession.execute()`` 返回值的最小替身:始终为空结果集。""" + + @staticmethod + def scalars() -> _EmptyScalars: + """返回空的 scalars 视图。""" + return _EmptyScalars() + + class _FakeShotSubresourceDB: """最小 DB 替身:仅覆盖镜头子资源接口测试所需行为。""" @@ -84,6 +107,15 @@ def add(self, obj: object) -> None: return raise TypeError(f"Unsupported object type: {type(obj)!r}") + async def execute(self, *_args, **_kwargs) -> "_EmptyResult": + """最小 execute 替身。 + + 删除对白时,路由会调用 ``mark_pending_by_linked_dialog_line`` 查询与该对白关联的 + ShotExtractedDialogueCandidate。本替身不维护 candidate 集合,因此返回空结果集, + 等价于「没有已接受的候选关联到该对白」——正是本用例要覆盖的场景。 + """ + return _EmptyResult() + async def flush(self) -> None: return None diff --git a/backend/tests/test_skills_integration.py b/backend/tests/test_skills_integration.py index a0632bc3..cb07144a 100644 --- a/backend/tests/test_skills_integration.py +++ b/backend/tests/test_skills_integration.py @@ -14,5 +14,5 @@ class TestAppIntegration: def test_health_returns_ok(self, client: TestClient) -> None: response = client.get("/health") assert response.status_code == 200 - assert response.json() == {"code": 200, "message": "success", "data": {"status": "ok"}} + assert response.json() == {"code": 200, "message": "success", "data": {"status": "ok"}, "meta": None} diff --git a/docs/adr/ADR-014-cas-production-pipeline.md b/docs/adr/ADR-014-cas-production-pipeline.md new file mode 100644 index 00000000..204570b5 --- /dev/null +++ b/docs/adr/ADR-014-cas-production-pipeline.md @@ -0,0 +1,110 @@ +# ADR-014 — CAS Production Pipeline (MVP Skeleton) + +- Status: **Accepted** (implemented in Sprint 4 as a mock-only, deterministic skeleton). +- Date: 2026-07-24 +- Related: [ADR-012](ADR-012-episode-importer.md) (EpisodePackage importer), + [ADR-013](ADR-013-cas-import-ledger.md) (import ledger), + [`docs/cas-production-mvp.md`](../cas-production-mvp.md), + contract: [`docs/crypto-animal-studio/episode-package-v1.md`](../crypto-animal-studio/episode-package-v1.md). +- Scope: new tables `cas_production_jobs` / `cas_production_shots` / `cas_production_artifacts` + (migration `backend/sql/010`), provider boundaries + mock providers, deterministic prompt + builder, ArtifactManager, orchestrator, manifest, API, CLI. + +--- + +## 1. Context + +The importer (ADR-012) turns an EpisodePackage into Jellyfish creative records +(Chapter/Shot/…). Producing an actual video is a **different concern**: it is long-running, +retryable, failure-prone, provider-dependent, and produces files. Sprint 4 builds the +smallest end-to-end, deterministic, traceable production skeleton — with **mock providers +only** — so that real image/video/voice integration in a later sprint has a stable frame to +plug into. + +## 2. Decision + +### 2.1 Production state is separate from creative domain models + +Production state lives in dedicated tables (`cas_production_*`) and **never** mutates or +duplicates Jellyfish creative entities (Project, Chapter, Shot, Asset, Character, Actor, +Task, Provider). Reasons: + +- **Different lifecycle.** A creative Shot is authored once and is stable; a production run + is attempted, fails, and is retried many times. Mixing run state into `Shot` would pollute + a stable creative record with volatile execution data. +- **Many runs per shot.** One creative Shot can be produced repeatedly (new job each run). + A 1:1 field on `Shot` cannot represent N runs. +- **Boundary rule.** The project forbids parallel creative systems; `CasProductionShot` + therefore stores only production status/prompts/errors plus `source_shot_id` as a **weak + reference**, not a replacement Shot. + +### 2.2 Artifact First + +Every stage output is registered as an **Artifact** row (type, stage, provider, model, path, +mime, checksum, metadata) pointing at a real file. Rationale: + +- **Traceability**: the manifest can reconstruct exactly what was produced, by whom, with + which model, and verify it via checksum. +- **Retry correctness**: "has this already been produced successfully?" becomes a concrete, + verifiable question (DB row + file exists + checksum matches) rather than a guess. +- **Provider independence**: artifacts are the contract between stages, so swapping a mock + provider for a real one changes nothing downstream. + +### 2.3 Providers are adapters + +`ImageProvider` / `VideoProvider` / `VoiceProvider` / `Composer` are abstract boundaries +returning a common `GeneratedArtifact`. Core orchestration contains **no** provider model +names, SDKs, or API details, and providers **never invent filesystem paths** (paths come from +ArtifactManager). This keeps the orchestrator stable while providers churn. + +### 2.4 Mock providers before real providers + +Implementing mocks first lets us prove the *pipeline* — staging, persistence, artifacts, +failure, retry, manifest — deterministically, offline, with no API keys, cost, latency, or +flakiness. Mocks write **real files** (not in-memory success) so filesystem/DB consistency and +checksums are genuinely exercised. Real providers in Sprint 5 then swap in behind the same +interfaces. + +### 2.5 Retry semantics + +- A failed job records the **failed stage** in `current_stage` and an actionable + `error_message`; the failing shot is marked failed. +- Retry **restarts from the failed stage** and reruns that stage and all later stages. +- Earlier artifacts are **reused** when the DB row exists, the file exists, and the checksum + matches; they are not regenerated. +- The package hash is **always re-validated** before resuming (even when starting at a later + stage) so a retry cannot silently produce a different episode. +- All successful artifacts are preserved on failure — nothing is deleted or rolled back on + the filesystem. + +### 2.6 Filesystem ownership + +ArtifactManager is the **only** component that constructs paths and creates directories: + +``` +storage/cas/productions/{project_id}/{episode_id}/{job_id}/ + manifest.json + shots/{sequence}-{shot_id}/{prompt.json,image/,video/,voice/,subtitle/} + final/final_video.txt +``` + +Path segments are sanitized (no traversal), stored in the DB as **relative POSIX paths** +(portable across Windows/Linux), and the storage root is overridable via `CAS_STORAGE_ROOT`. + +### 2.7 Exclusions for this sprint + +Explicitly **not** in Sprint 4: real image/video/voice/music providers, FFmpeg, Celery, +Redis, S3 upload, LLM calls (prompt building is pure string assembly), frontend UI, changes +to EpisodePackage v1, and changes to the importer contract. Execution is synchronous. + +## 3. Consequences + +**Positive.** Deterministic and offline-testable; DB state provably matches the filesystem; +retry is cheap and safe; providers can be replaced without touching orchestration; creative +records remain untouched. + +**Trade-offs.** Synchronous execution will not scale to real long-running generation — Sprint +5+ must move execution onto Jellyfish's existing task center (never a second task system). +Local filesystem storage will need to migrate to the existing S3/RustFS storage layer. +Mock artifacts are `.txt` placeholders, so mime types are provisional until real providers +land. diff --git a/docs/adr/ADR-016-episode-package-v1-1.md b/docs/adr/ADR-016-episode-package-v1-1.md new file mode 100644 index 00000000..80efd937 --- /dev/null +++ b/docs/adr/ADR-016-episode-package-v1-1.md @@ -0,0 +1,266 @@ +# ADR-016 — EpisodePackage v1.1 (Additive Contract Extension) + +- Status: **Accepted** +- Date: 2026-07-26 +- Deciders: Product Owner (approval), Architect/CTO (review), Engineer (record) +- Specification: [`EpisodePackage-v1.1-proposal.md`](../crypto-animal-studio/EpisodePackage-v1.1-proposal.md) +- Related: [ADR-012](ADR-012-episode-importer.md), [ADR-013](ADR-013-cas-import-ledger.md), + [ADR-014](ADR-014-cas-production-pipeline.md), [ADR-015](ADR-015-crypto-animal-bible-v1-canon.md) +- Canon inputs: [Bible v1](../crypto-animal-studio/Crypto_Animal_Bible_v1.md), + [gap report](../crypto-animal-studio/bible-v1-implementation-gap-report.md), + [EP001](../crypto-animal-studio/episodes/EP001-btc-breaks-out-bruno-celebrates-too-early.md) +- Scope: **specification decision only.** No code, schema, test, sample, or migration is changed by + this ADR. + +--- + +## 1. Context + +ADR-015 made Crypto Animal Bible v1 canon and required that any schema evolution be a separately +approved v1.1/v2 proposal. EP001 is the first canonical episode, and designing it confirmed the +gap report's findings: EpisodePackage v1 cannot represent a Traditional Chinese subtitle track +(G-05), the post-production fact card and disclaimer (G-06), the 9:16 / runtime output spec +(G-04), structured market-data provenance with a pre-render data lock, or canonical reference +assets (G-09). + +Two properties of the current implementation constrain any solution. Every one of the 14 v1 models +sets `ConfigDict(extra="forbid")`, and `schema_version` is validated by **strict equality** to +`"1.0"`. Unknown fields and unknown versions are therefore hard errors — deliberate strictness that +has caught real contract drift. + +EpisodePackage is also **not column-mapped** in the database: the import ledger persists only a +SHA-256 `payload_hash` and `schema_version VARCHAR(16)`, and the production tables persist derived +prompts and artifact rows. This materially affects the migration question. + +## 2. Decision + +Adopt a **minimal, additive EpisodePackage v1.1**: + +1. **Version identification reuses the existing `schema_version` field.** No new version field is + introduced. v1.1 documents carry `"1.1"`. Parsers accept `{"1.0", "1.1"}`; anything else — + including an absent value, which remains a required-field error as in v1 — is rejected with a + typed `UnsupportedSchemaVersionError` surfaced as HTTP 422. +2. **Six optional root objects** are added: `output`, `localization`, `fact_card`, `market_data`, + `references`, `post_production`. +3. **Five optional shot fields** are added: `beginning_state`, `ending_state`, + `generation_risks[]`, `regeneration_fallback`, `overlay_ids[]`. +4. **No existing field changes** name, type, or semantics. In particular + `creative_direction.target_duration_seconds` is **not** reinterpreted as authoritative runtime, + and `shots[].duration_seconds` remains the authoritative shot duration. +5. **New timing fields are integer milliseconds** (`*_ms`); existing second-based fields are + untouched. +6. **Derived timing is authoritative.** Declared `output.*_ms` values are assertions checked + against the derived sums with a ±50 ms tolerance; mismatch is a pre-render error. +7. **The fact card is never a shot.** It is a post-production element and must not appear in + `shots[]`; EP001 remains exactly four generated shots. +8. **All readable financial text is post-production.** Generated imagery is never required to + contain legible financial text. +9. **Five validation stages** are formalised: design, pre-render/data-lock, provider-input, + post-production, publish. +10. **The package stays provider-neutral.** No provider names, models, endpoints, credentials, or + request payloads may appear in it. +11. **Subtitles are conditionally required, never universally required.** Tracks stay structurally + optional; an episode creates its own obligation by declaring + `localization.required_publish_language_tags[]`. EP001 declares `["zh-Hant"]`. Legacy v1 + packages declare nothing and therefore never fail for missing subtitles. A declared-but-empty + track is invalid from post-production onward. Rendering remains post-production. +12. **One timing coordinate system: episode-absolute integer milliseconds**, with time zero at the + first frame of generated footage, the appended fact card occupying + `derived_generated_ms → derived_total_ms`, and the bound + `0 ≤ start_ms < end_ms ≤ derived_total_ms`. Subtitle cues use the identical system. `shot_id` + is an association only and must never introduce shot-relative offsets. +13. **Market facts keep the placeholder-capable string representation** as a deliberate minimal-v1.1 + compromise: placeholders are legal at design, rejected at data lock, required to parse per their + declared semantic rule once locked, and re-checked at publish. Typed facts plus a separate + `placeholders{}` map are a possible **v2** improvement and are out of scope. +14. **One rounding strategy — `round_half_up`** — applied via an explicit helper everywhere + seconds are converted to milliseconds; implementations must not rely on Python's banker's + `round()`. +15. **API ingestion accepts both versions through the existing request models.** No new route is + added. `CreateProductionJobRequest`, `RetryProductionJobRequest`, and `ImportEpisodeRequest` + declare `episode_package: AnyEpisodePackage` with `union_mode="left_to_right"` — the union tries + `EpisodePackageV11` first, then `EpisodePackage`. Version selection remains owned solely by each + model's `allowed_schema_versions`, so no dispatch logic is duplicated. `left_to_right` is + required because the default smart union could match the V11 payload against its parent class + and silently drop v1.1 fields. Missing `schema_version` still yields the existing `missing` + error, unknown versions yield an explicit 422, and payloads are never mutated or upgraded. + OpenAPI represents the field as an `anyOf` of both schemas. +16. **Reference-asset validation is split by layer.** Canonical package validation owns key + consistency, required-reference presence **for characters actually used by shots**, path/asset-ID + shape, and rejection of unsafe or provider-specific references. Existence checking against an + asset registry or the repository, and resolution into provider-ready input, belong to the + **provider-input / orchestration preflight**. **Schema parsing performs no filesystem, registry, + or network I/O.** No registry abstraction exists yet; existence checking is deferred to + provider integration. +17. **Re-timing belongs to post-production assembly.** Changing shot order or duration invalidates + absolute subtitle and overlay timing; the post-production assembly stage recomputes them, and + the package must then be **revalidated from `pre_render_data_lock` through `publish`**. + Automatic re-timing is not implemented in v1.1 — validation fails on stale timing so the fix is + explicit. + +## 3. Detailed compatibility policy + +- **v1 documents remain permanently valid.** Every added field is optional; a `"1.0"` document + parses unchanged under a v1.1 parser, and its canonical payload hash is byte-identical, so the + existing sample, the ledger rows, and the 68 CAS tests are unaffected. +- **Version parsing rules.** `schema_version` remains the sole discriminator; v1 keeps `"1.0"` with + its existing semantics; v1.1 declares `"1.1"`; the upgraded parser accepts an explicit + `SUPPORTED_SCHEMA_VERSIONS = {"1.0", "1.1"}` membership set (never a range or prefix match). + A **missing** `schema_version` follows the behaviour already in the implementation — it is a + required field, so absence is a missing-field validation error; no fallback or default version is + invented. **Unknown versions fail explicitly** with a typed `UnsupportedSchemaVersionError` + (HTTP 422) and are never coerced to the newest version. **No version is silently upgraded during + parsing**, and **existing v1 payload hashes are never rewritten or recomputed** merely because a + v1.1-capable parser read the document. +- **Compatibility is backward, not forward.** Because of `extra="forbid"` and strict version + equality, a v1.1 document **cannot** be parsed by unmodified v1 code — with or without new + fields present. This is accepted deliberately and stated plainly rather than concealed. +- **No silent reinterpretation.** No existing field acquires new meaning. Where v1 already has a + suitable field (`shots[].continuity_notes`, `shots[].camera.movement`) v1.1 adds a *rule*, not a + duplicate field. +- **Defaults are derived, never written back.** When `output` is absent, defaults (9:16, + 1080×1920, 30 fps, vertical) apply to the derived view only; the source document is never + mutated. +- **Value semantics are explicit.** *missing*, `null`, *empty*, *defaulted*, and *invalid* are + distinguished, so "not specified" and "specified as nothing" never collapse together. +- **The legacy demo sample is immutable.** `samples/cas/demo_episode.json` must not be edited; a + canonical v1.1 sample is **added** alongside it, because Sprint 4 tests and the ledger payload + hash depend on the legacy file's exact bytes. + +## 4. Alternatives considered + +| Alternative | Verdict | +|---|---| +| Full **v2** redesign | Rejected — violates the minimal-change mandate and breaks importer, hashes, and tests with no EP001 benefit. | +| Relax models to `extra="ignore"` for forward compatibility | Rejected — would silently absorb typos and contract drift, destroying the strictness that gives the contract its value. | +| Carry subtitles/fact card inside the free-form `shots[].metadata` dict | Rejected — an untyped shadow contract; cue-level timing rules could not be validated. | +| Reinterpret `target_duration_seconds` as authoritative runtime | Rejected — a silent semantic change to an existing field, and an int cannot carry millisecond precision. | +| Add a second `package_version` field | Rejected — duplicate source of truth for versioning. | +| Store subtitles as an SRT/VTT blob | Rejected — not validatable or queryable at cue level. | +| Make the fact card a fifth generated shot | Rejected — would push readable financial text into AI-generated imagery and break the four-shot canon. | +| Embed provider URLs/credentials for reference assets | Rejected — security and provider-neutrality violation; adapters resolve `asset_id` themselves. | + +## 5. Consequences + +**Positive.** EP001 becomes representable end to end (9:16, 24.0 s, four shots + 3.0 s card, +zh-Hant subtitles, Milo's final line, optional notification overlay, provenance with a data lock, +reference assets, disclaimer). Placeholder-bearing designs are legal while rendering and publishing +are gated. No database migration. Existing behaviour and tests are untouched. + +**Trade-offs.** Two schema versions must be supported in parsing and tests. v1.1 documents are +unreadable by unmodified v1 code. Seconds (existing) and milliseconds (new) coexist, requiring a +documented conversion. The contract grows meaningfully in surface area, and validation logic splits +into five stages that must each be tested. + +**Risks.** Mis-specified subtitle cues could reach rendering if stage rules are skipped; the ±50 ms +timing tolerance needs to be honoured consistently; `market_data` placeholder-capable strings rely +on the data-lock rule rather than on types. + +## 6. Migration strategy + +Approve → implement the parser (`SUPPORTED_SCHEMA_VERSIONS`, optional models, typed unsupported- +version error) → implement the five validators as separate callables → **add** a canonical v1.1 +sample without touching the legacy one → let the importer and production pipeline read new fields +opportunistically → hand `beginning_state`/`ending_state`/`references`/`generation_risks` to Prompt +Builder v1 in its own sprint. + +No data migration and no backfill: stored ledger rows keep `schema_version = "1.0"` and remain +correct, and no existing artifact or hash changes. + +## 7. Validation-stage decision + +Validation is explicitly **staged rather than monolithic**: + +| Stage | Placeholders | Gate | +|---|---|---| +| Design | allowed | structural and referential integrity | +| Pre-render / data-lock | **forbidden** | facts resolved and parseable per their semantic rule, `data_lock.status = "locked"`, timing assertions within ±50 ms, format resolved, subtitle cue rules | +| Provider-input | n/a | required generation inputs and references present; no forbidden URL or credential anywhere | +| Post-production | n/a | required overlays placeable; **every declared required publish language has a non-empty subtitle track**; overlay bounds inside `[0, derived_total_ms]` | +| Publish | **forbidden** | required subtitle tracks complete, disclaimer present, prohibited-phrase scan passes, runtime in the 15–30 s canonical range, **placeholder check repeated** | + +A design document containing `{{PLACEHOLDER}}` tokens is **valid at design stage and invalid at +pre-render and publish**. This is the mechanism that prevents unresolved factual placeholders from +reaching rendering or publishing. + +## 8. Provider-neutrality decision + +The canonical package describes **what the episode is**, never **how a vendor is called**. + +**Allowed:** + +- a **public market-data provenance URL** in `market_data.source_url`; +- a stable **repository-relative asset path** (`references.*.path`) or canonical opaque + **asset ID** (`references.*.asset_id`). + +**Forbidden at every version:** + +- provider **API endpoints**; +- **signed URLs**; +- **temporary / expiring download URLs**; +- **account-specific URLs**; +- **credentials, API keys, authorization headers, or tokens**; +- **provider-native generation request payloads**. + +**`source_url` is evidence/provenance metadata, not a provider execution endpoint.** It exists so a +human or auditor can verify a market claim; it is never fetched to drive generation and carries no +authentication. Provider adapters resolve `asset_id` and construct their own vendor payloads at call +time. Provider-specific derived data belongs to the production layer +(`cas_production_artifacts.provider`, `provider_model`, `metadata_json`), which already records it — +keeping canonical contract fields and derived provider payload fields separate. + +## 9. Rollback strategy + +Because the change is additive and unreleased, rollback is cheap and requires no data work: + +1. Revert `SUPPORTED_SCHEMA_VERSIONS` to strict `"1.0"` equality and remove the optional models + from `schemas/episode_package.py`. +2. Delete the added v1.1 sample and its tests. The legacy `demo_episode.json` was never modified. +3. No database change to undo (no migration was introduced); ledger rows and payload hashes are + unaffected. +4. Any v1.1 documents already authored become unparseable — acceptable while the feature is + unreleased, and the reason production adoption should follow, not precede, acceptance. + +## 10. Conditions required before status changes from Proposed to Accepted + +This ADR **remains `Proposed`** until every condition below is demonstrably satisfied. Specification +approval alone does not move it to `Accepted`; the implementation must exist and be verified. + +**Implementation conditions** + +1. **Schema implementation complete** — the six optional root objects and five optional shot fields + exist in `schemas/episode_package.py` exactly as specified, with + `SUPPORTED_SCHEMA_VERSIONS = {"1.0", "1.1"}` and the typed `UnsupportedSchemaVersionError`. +2. **Lifecycle validators implemented** — design, pre-render/data-lock, provider-input, + post-production, and publish exist as **separate callables**, so a design-stage document is never + blocked by data-lock rules. +3. **EP001-shaped fixture validates** — a fixture matching the §8 example passes design validation + while holding placeholders, and **fails** pre-render and publish until data-locked; the + data-locked variant passes both. + +**Regression conditions** + +4. **Legacy v1 sample unchanged** — `samples/cas/demo_episode.json` is byte-identical, and its + canonical payload hash is unchanged. +5. **All existing v1 tests pass** — the CAS baseline (68) stays green with no modifications to + existing tests. +6. **New v1.1 tests pass** — covering version handling, subtitle cue rules, the conditional + subtitle-requirement policy, overlay absolute-timing bounds, timing assertions and rounding, + data-lock placeholder rejection, and provider-neutrality (forbidden URL/credential) rejection. + +**Confirmation conditions** + +7. **No database migration** — confirmed against the code that EpisodePackage remains non-column- + mapped, `cas_import_ledger.schema_version VARCHAR(16)` already accommodates `"1.1"`, and no + table changes. +8. **Documentation and implementation field paths match exactly** — every field path in this ADR and + in the proposal resolves to a real implemented path, with no drift in names, nesting, or types. + +**Governance conditions** + +9. Product Owner approves the additive field set and the EP001 acceptance scenarios. +10. Architect/CTO confirms the backward-only compatibility policy. +11. The two remaining open questions in the proposal (§15) are answered or explicitly deferred with + an owner. +12. An implementation sprint is scoped and authorized; Prompt Builder v1 is confirmed as a separate + sprint that consumes — but does not define — this contract. diff --git a/docs/cas-production-mvp.md b/docs/cas-production-mvp.md new file mode 100644 index 00000000..5ace0395 --- /dev/null +++ b/docs/cas-production-mvp.md @@ -0,0 +1,117 @@ +# CAS Production MVP (Sprint 4) + +Deterministic, traceable, **mock-only** end-to-end production skeleton that consumes a valid +EpisodePackage and produces artifacts plus a manifest. Design rationale: [ADR-014](adr/ADR-014-cas-production-pipeline.md). + +> No real image/video/voice/music providers, no FFmpeg, no LLM, no Celery/Redis in this sprint. +> Execution is synchronous. EpisodePackage v1 and the importer are unchanged. + +## Module layout + +``` +backend/app/crypto_animal_studio/production/ +├── enums.py # JobStatus, Stage, ArtifactType, STAGE_ORDER +├── models.py # CasProductionJob / CasProductionShot / CasProductionArtifact +├── prompt_builder.py # deterministic prompts (no LLM) +├── artifact_manager.py # paths, dirs, checksums, registration, reuse +├── orchestrator.py # stage pipeline, failure, retry, manifest +├── cli.py # python -m app.crypto_animal_studio.production.cli run +└── providers/ + ├── base.py # ImageProvider/VideoProvider/VoiceProvider/Composer + GeneratedArtifact + └── mock.py # deterministic mock providers (write real files) +``` + +## Pipeline + +`validate → prompt_build → image_generation → video_generation → audio_generation → +subtitle_generation → composition → finalize` + +1. **validate** — re-hash the EpisodePackage and compare with the job's stored hash. +2. **prompt_build** — deterministic prompts per shot; writes `prompt.json`. +3. **image/video/audio** — mock providers write `image.txt` / `video.txt` / `voice.txt`. +4. **subtitle_generation** — writes `subtitle.txt` from the shot's dialogue. +5. **composition** — mock composer writes `final/final_video.txt`. +6. **finalize** — marks shots/job completed and writes `manifest.json`. + +## Storage convention + +``` +storage/cas/productions/{project_id}/{episode_id}/{job_id}/ + manifest.json + shots/{sequence}-{shot_id}/ + prompt.json + image/image.txt + video/video.txt + voice/voice.txt + subtitle/subtitle.txt + final/final_video.txt +``` + +Paths are produced **only** by `ArtifactManager`; the DB stores relative POSIX paths. +Override the root with the `CAS_STORAGE_ROOT` environment variable. + +## Determinism + +`PromptBuilder v0` assembles prompts from EpisodePackage fields in a fixed order (visual +style → scene → characters → action → camera → shot prompt); dialogue is ordered by `order`. +No LLM, no randomness, no timestamps in artifact content — the same package always yields +byte-identical prompts and mock artifacts. + +## Failure and retry + +- On failure: the current shot (if any) and the job are marked `failed`, `current_stage` + records the failed stage, `error_message` holds an actionable message, and **all successful + artifacts are preserved**. A manifest is still written for traceability. +- On retry: execution restarts **at the failed stage** and runs it plus all later stages. + Earlier artifacts are reused when the DB row exists, the file exists, and the checksum + matches. The package hash is re-validated first, so a changed payload fails instead of + silently producing a different episode. + +## API + +Registered under the existing API v1 + CAS mount (no separate FastAPI app): + +| Method | Path | +|---|---| +| POST | `/api/v1/crypto-animal-studio/production/jobs` | +| GET | `/api/v1/crypto-animal-studio/production/jobs/{job_id}` | +| GET | `/api/v1/crypto-animal-studio/production/jobs/{job_id}/artifacts` | +| POST | `/api/v1/crypto-animal-studio/production/jobs/{job_id}/retry` | + +Request body for create: + +```json +{ "project_id": "demo-project", "episode_package": { }, "mode": "mock" } +``` + +All responses use the standard `ApiResponse` envelope. + +## CLI + +Windows PowerShell friendly (single line): + +```powershell +uv run python -m app.crypto_animal_studio.production.cli run --project-id demo-project --episode-package samples/cas/demo_episode.json --provider-mode mock --create-tables +``` + +Prints `status`, `job_id`, `manifest` path, and `final_output` path. + +## Database + +Migration `backend/sql/010-add-cas-production-tables.sql` creates the three tables with +indexes and FKs (`shots`/`artifacts` cascade from the job). `project_id` / `episode_id` / +`source_shot_id` are intentionally **weak references** (no FK) so production stays decoupled +from the creative domain. + +## Tests + +`backend/tests/test_cas_production.py` (models, determinism, paths, checksums, mock file +creation, full orchestration, failure, retry/reuse, manifest) and +`backend/tests/test_cas_production_api_cli.py` (4 endpoints + CLI). + +## Known limitations + +- Synchronous execution only; long real generations will need the existing task center. +- Local filesystem storage; S3/RustFS integration is future work. +- Mock artifacts are `.txt` placeholders (`text/plain`). +- `music` and `log` artifact types are defined but not produced yet. diff --git a/docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md b/docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md new file mode 100644 index 00000000..0789f156 --- /dev/null +++ b/docs/crypto-animal-studio/EpisodePackage-v1.1-proposal.md @@ -0,0 +1,810 @@ +# EpisodePackage v1.1 — Additive Contract Proposal + +Status: **Proposed — specification only.** Nothing here is implemented. Decision record: +[ADR-016](../adr/ADR-016-episode-package-v1-1.md). +Canonical inputs: [Bible v1](Crypto_Animal_Bible_v1.md), [ADR-015](../adr/ADR-015-crypto-animal-bible-v1-canon.md), +[gap report](bible-v1-implementation-gap-report.md), [EP001](episodes/EP001-btc-breaks-out-bruno-celebrates-too-early.md). +Derived by read-only inspection of `backend/app/crypto_animal_studio/` at commit `c2b3ea5` plus +the uncommitted Sprint 4 production module. + +--- + +## 1. Executive summary + +EP001 cannot be represented by EpisodePackage v1. Five things are missing: a Traditional Chinese +subtitle track, the post-production fact card, an explicit 9:16 / runtime output spec, structured +market-data provenance with a data-lock gate, and canonical reference assets. This proposal adds +**six optional top-level objects** and **five optional shot fields** — no existing field is +renamed, retyped, or reinterpreted, and no v2 redesign is attempted. + +The one unavoidable consequence: every v1 model sets `extra="forbid"` and `schema_version` is +validated by strict equality to `"1.0"`, so a **v1.1 document cannot be parsed by unmodified v1 +code**. Compatibility therefore runs in one direction — *v1 documents stay permanently valid under +a v1.1 parser*, which is what the existing samples and tests require. §10 states this precisely +rather than claiming symmetric compatibility. + +Based on the actual code, **no database migration is required** (§12). + +## 2. Current v1 contract inventory + +Source of truth: `backend/app/crypto_animal_studio/schemas/episode_package.py` (14 models, all +`ConfigDict(extra="forbid")`) and `domain/episode_package.py` (`SCHEMA_VERSION = "1.0"`). + +| Model | Fields | +|---|---| +| `EpisodePackage` (root) | `schema_version`, `episode_id`, `title`, `logline`, `language`, `source`, `creative_direction`, `characters[]`, `assets`, `shots[]`, `metadata` | +| `NewsSource` | `source_type`, `headline`, `summary`, `source_url?`, `published_at?`, `factual_notes` | +| `CreativeDirection` | `format`, `tone`, `target_duration_seconds` (int > 0), `visual_style`, `comedy_style`, `continuity_notes` | +| `CharacterSpec` | `character_key`, `display_name`, `role`, `description`, `actor_key?`, `costume_key?`, `voice_profile?`, `continuity_notes` | +| `AssetLibrary` | `actors[]`, `scenes[]`, `props[]`, `costumes[]` (each `*_key`, `display_name`, `description`) | +| `Shot` | `shot_id`, `sequence` (>0), `title`, `duration_seconds` (float > 0), `script_excerpt`, `camera?`, `action`, `dialogue[]`, `character_keys[]`, `scene_key?`, `prop_keys[]`, `costume_keys[]`, `image_prompt`, `video_prompt`, `negative_prompt`, `continuity_notes`, `metadata` | +| `CameraSpec` | `shot_type?`, `angle?`, `movement?` (CAS-local enums) | +| `DialogueLine` | `order` (>0), `character_key?`, `text`, `line_mode` | +| `EpisodeMetadata` | `created_at?`, `generator`, `model`, `prompt_version`, `tags[]` | + +**Observed consumption.** `shots[].duration_seconds` is widely consumed (importer +`round_duration()` → `ShotDetail.duration`; orchestrator; prompt builder). `language` and +`creative_direction.target_duration_seconds` currently have **zero consumers** — they are carried +but unused, so giving runtime a precise home in v1.1 creates no behavioural conflict. + +## 3. Confirmed v1 gaps exposed by EP001 + +| # | Gap | Gap-report ID | +|---|---|---| +| 1 | No subtitle representation (zh-Hant track, cue timing, speaker) | G-05 | +| 2 | No fact card / disclaimer / CTA representation | G-06 | +| 3 | No aspect ratio, width/height, fps, or authoritative runtime | G-04 | +| 4 | No structured market provenance or data-lock gate | §3 of EP001 | +| 5 | No canonical reference assets (Bible version, character/environment/prop refs) | G-09 | +| 6 | No post-production overlay plan (chart labels, notification, fact card) | G-07/G-08 | +| 7 | No shot begin/end state, generation risks, or regeneration fallback | EP001 §5 | + +## 4. Design principles + +1. **Additive only.** New optional objects and fields; nothing existing changes. +2. **No duplicate truth.** Where a value can be derived, the derived value is authoritative and + any declared value is a checked assertion (§9.2). +3. **Explicit value semantics.** *missing* ≠ *null* ≠ *empty* ≠ *defaulted* ≠ *invalid* (§6.1). +4. **Provider-neutral.** No provider names, models, endpoints, credentials, or request shapes. +5. **Staged validation.** Design-stage packages may be incomplete; rendering and publishing may + not (§9). +6. **Post-production owns readable text.** Nothing readable and factual is required from a + generative model. +7. **Storage-practical.** Pure JSON, no cyclic references, bounded field sizes. + +## 5. Proposed additive fields + +**Root (all optional):** `output`, `localization`, `fact_card`, `market_data`, `references`, +`post_production`. + +**Shot (all optional):** `beginning_state`, `ending_state`, `generation_risks[]`, +`regeneration_fallback`, `overlay_ids[]`. + +**Deliberately not added** (already representable in v1): continuity text → `shots[].continuity_notes`; +"exactly one dominant camera movement" → `shots[].camera.movement` (enforced by a *rule*, not a new +field); episode tone/style → `creative_direction`. + +### 5.1 Timing units + +New timing fields are **integer milliseconds** (`*_ms`) — exact, JSON-safe, no float drift, and +directly usable by subtitle and NLE tooling. Existing `shots[].duration_seconds` (float seconds) +is **unchanged and remains authoritative for shot duration**; conversion is +`round(duration_seconds * 1000)`. Mixing is intentional and one-directional: v1.1 never redefines +the v1 field. + +## 6. Field-by-field schema table + +Validation-stage column: **D**=design, **L**=pre-render/data-lock, **P**=provider-input, +**T**=post-production, **B**=publish. + +### 6.1 Value semantics (applies to every optional field) + +| State | Meaning | Treatment | +|---|---|---| +| missing (key absent) | not specified | default applied where one exists; otherwise feature off | +| `null` | explicitly none | same as missing, but records an intentional decision | +| empty (`""`, `[]`, `{}`) | specified as nothing | valid; may fail a *stage* rule (e.g. empty subtitle track at publish) | +| defaulted | value supplied by the parser | recorded in derived output, never written back into the source document | +| invalid | violates type or rule | hard validation error at the stage that owns the rule | + +### 6.2 Root + +| Field path | Type | Req. | Default | Stage | Purpose | v1 compatibility | +|---|---|---|---|---|---|---| +| `schema_version` | string | **required** | — | D | `"1.0"` or `"1.1"` | existing field, reused; no new version field | +| `output` | object | optional | see §6.3 | D/L | Format, dimensions, runtime | absent ⇒ v1 defaults applied | +| `localization` | object | optional | `null` | D/L/B | Spoken language + subtitle tracks | absent ⇒ no subtitles | +| `fact_card` | object | optional | `null` | D/L/T/B | Post card, disclaimer, CTA | absent ⇒ no card | +| `market_data` | object | optional | `null` | D/L/B | Provenance + data-lock | absent ⇒ non-market episode | +| `references` | object | optional | `null` | D/P | Bible + reference assets | absent ⇒ unconstrained identity | +| `post_production` | object | optional | `null` | D/T/B | Overlay plan | absent ⇒ no overlays | + +### 6.3 `output` + +| Field path | Type | Req. | Default | Stage | Purpose | v1 compatibility | +|---|---|---|---|---|---|---| +| `output.aspect_ratio` | string `"W:H"` | optional | `"9:16"` | L | Canonical framing | legacy ⇒ default | +| `output.width` | int > 0 | optional | `1080` | L | Render width | legacy ⇒ default | +| `output.height` | int > 0 | optional | `1920` | L | Render height | legacy ⇒ default | +| `output.fps` | int > 0 | optional | `30` | L | Frame rate | legacy ⇒ default | +| `output.orientation` | enum `vertical\|horizontal\|square` | optional | `"vertical"` | L | Orientation metadata | legacy ⇒ default | +| `output.generated_footage_ms` | int ≥ 0 | optional | derived | L | **Assertion** of Σ shot durations | mismatch ⇒ error (§9.2) | +| `output.total_runtime_ms` | int > 0 | optional | derived | L | **Assertion** of final runtime | mismatch ⇒ error (§9.2) | +| `output.safe_area.subtitle_bottom_pct` | number 0–50 | optional | `18` | T | Subtitle band | legacy ⇒ default | +| `output.safe_area.margin_pct` | number 0–25 | optional | `6` | T | General safe margin | legacy ⇒ default | + +`creative_direction.target_duration_seconds` (v1) is **not** reinterpreted: it stays an +author-intent hint. If `output.total_runtime_ms` is present it is the assertion that gets checked; +otherwise the runtime is derived and `target_duration_seconds` is advisory only. + +### 6.4 `localization` + +| Field path | Type | Req. | Default | Stage | Purpose | v1 compat | +|---|---|---|---|---|---|---| +| `localization.spoken_language` | BCP 47 | optional | falls back to root `language` | L | Dialogue language | root `language` unchanged | +| `localization.required_publish_language_tags[]` | array of BCP 47 | optional | `[]` | T/B | Languages this episode **must** ship subtitles for | absent/empty ⇒ no subtitle requirement | +| `localization.subtitle_tracks[]` | array | optional | `[]` | L/B | Subtitle tracks | absent ⇒ none | +| `…tracks[].language_tag` | BCP 47 (e.g. `zh-Hant`) | **required in track** | — | L | Track language | — | +| `…tracks[].is_primary` | bool | optional | `false` | L | Default track | — | +| `…tracks[].rendering` | enum `post_production\|burned_in\|sidecar` | optional | `"post_production"` | T | Where rendering happens | — | +| `…tracks[].cues[]` | array | **required in track** | — | L/B | Cue list | — | +| `…cues[].cue_id` | string | **required** | — | L | Stable ID | — | +| `…cues[].start_ms` | int ≥ 0 | **required** | — | L | Cue in-point | — | +| `…cues[].end_ms` | int > `start_ms` | **required** | — | L | Cue out-point | — | +| `…cues[].text` | non-empty string | **required** | — | L/B | Translated text | — | +| `…cues[].speaker_character_key` | string | optional | `null` | L | Speaker link | must exist in `characters[]` | +| `…cues[].shot_id` | string | optional | `null` | L | Shot association | must exist in `shots[]` | + +Subtitle rendering stays a **post-production concern** by default (`rendering: +"post_production"`); `burned_in` and `sidecar` are declarative only and do not change the contract. + +#### 6.4.1 Subtitle requirement policy (finalized) + +Subtitles are **conditionally required**, never universally required: + +1. **Structurally optional.** `localization` and `subtitle_tracks[]` remain optional for all v1 and + v1.1 packages. A package with no subtitle track is structurally valid. +2. **Declared requirement creates an obligation.** If an episode declares one or more language tags + in `localization.required_publish_language_tags[]`, then a subtitle track whose `language_tag` + matches each declared tag **must exist and be non-empty** at **post-production** and **publish** + validation. +3. **EP001 specifically** declares `required_publish_language_tags: ["zh-Hant"]`, so EP001 cannot + pass publish validation without a complete zh-Hant track. This obligation comes from *the + episode's own declaration*, not from the schema. +4. **zh-Hant is never universally required.** No EpisodePackage is obliged to carry Traditional + Chinese (or any other language) unless it declares that requirement itself. +5. **Legacy v1 packages never fail for lack of subtitles.** A `"1.0"` document has no + `localization` object, therefore declares no required language, therefore has no subtitle + obligation at any stage. +6. **A declared-but-empty track is invalid from post-production onward.** A track present with + `cues: []`, or a required language whose track exists but has no cue covering any dialogue line, + passes *design* validation (authoring in progress) but **fails post-production and publish**. + Declaring a track is a commitment to fill it. +7. **Rendering stays post-production.** Satisfying the requirement means the cue data exists and is + valid; burning pixels remains a post-production step, and `rendering` never shifts that + responsibility into generation. + +### 6.5 `fact_card` + +| Field path | Type | Req. | Default | Stage | Purpose | v1 compat | +|---|---|---|---|---|---|---| +| `fact_card.duration_ms` | int > 0 | **required in object** | — | L | Card length | — | +| `fact_card.placement` | enum `append_after_shots\|overlay_tail` | optional | `"append_after_shots"` | L | Where it sits | — | +| `fact_card.localized[]` | array | **required in object** | — | L/B | Localized copy | — | +| `…localized[].language_tag` | BCP 47 | **required** | — | L | Language | — | +| `…localized[].body[]` | array of non-empty strings | **required** | — | L/B | Educational lines | — | +| `…localized[].disclaimer` | non-empty string | **required** | — | B | "Not financial advice" | — | +| `…localized[].cta` | string | optional | `null` | T | Optional CTA | — | +| `fact_card.readable_text_in_post` | const `true` | optional | `true` | T | Card text is composited | — | + +**Rule:** the fact card is **never** a shot. It must not appear in `shots[]`, and shot count is +unaffected — EP001 stays at exactly four generated shots. + +### 6.6 `market_data` + +| Field path | Type | Req. | Default | Stage | Purpose | +|---|---|---|---|---|---| +| `market_data.instrument` | string (e.g. `BTC-USD`) | **required in object** | — | L | Symbol | +| `market_data.timeframe` | string (e.g. `4h`) | **required in object** | — | L | Confirmation timeframe | +| `market_data.resistance_level` | string¹ | optional | `null` | L | Level being cleared | +| `market_data.price` | string¹ | optional | `null` | L | Price at the event | +| `market_data.price_move_pct` | string¹ | optional | `null` | L | Period move | +| `market_data.pullback_pct` | string¹ | optional | `null` | L | Escalation dip | +| `market_data.event_timestamp_utc` | ISO-8601 string¹ | optional | `null` | L | Observed move | +| `market_data.candle_close_timestamp_utc` | ISO-8601 string¹ | optional | `null` | L | Confirmation close | +| `market_data.as_of_utc` | ISO-8601 string¹ | **required at L** | — | L | Data "as of" | +| `market_data.source_name` | string | **required at L** | — | L/B | Source | +| `market_data.source_url` | string | optional | `null` | L | Source link | +| `market_data.factual_note` | string | **required at L** | — | L/B | Human-checked note | +| `market_data.ath_context` | string¹ | optional | `null` | L | Optional prior-high context | +| `market_data.data_lock.status` | enum `unresolved\|locked` | optional | `"unresolved"` | L/B | Lock state | +| `market_data.data_lock.locked_at_utc` | ISO-8601 | optional | `null` | L | When locked | + +¹ **Placeholder-capable**: during design these may hold `{{TOKEN}}` strings. Numeric values are +typed as strings precisely so a design-stage document stays schema-valid while unresolved; the +data-lock rule (§9.3) is what forces real values before rendering. + +#### 6.6.1 Market-fact value representation (finalized) + +v1.1 **retains the placeholder-capable string representation** for market facts. The policy: + +1. **Design stage may hold explicit placeholders** such as `{{RESISTANCE_LEVEL}}` or + `{{CANDLE_CLOSE_TIME_UTC}}`. This is legal and expected while an episode is being authored. +2. **Data-lock validation rejects every unresolved placeholder.** Any value matching the template + pattern `{{…}}` anywhere in `market_data`, `fact_card`, or overlay copy is a hard error at + pre-render. +3. **Once `data_lock.status = "locked"`, required numeric market facts must parse** according to + their declared semantic type or validation rule — e.g. `price` / `resistance_level` must parse + as a finite decimal number (an optional currency symbol, thousands separators, and surrounding + whitespace are stripped first); `price_move_pct` / `pullback_pct` must parse as a finite + decimal percentage (an optional trailing `%` is stripped); `*_utc` fields must parse as + ISO-8601 instants. +4. **Explicitly invalid at data lock:** empty strings; whitespace-only strings; `null` where the + field is not explicitly nullable; NaN-like strings (`"NaN"`, `"nan"`, `"inf"`, `"-inf"`, + `"None"`, `"null"`, `"undefined"`, `"TBD"`, `"?"`); and any unresolved template. +5. **Publish validation repeats the unresolved-placeholder check** — data lock is not trusted as a + permanent guarantee, because copy can be edited after locking. +6. **This is a deliberate minimal-v1.1 compromise**, not a claim that strings are the ideal + long-term representation for market data. It is chosen because it keeps v1.1 additive and lets a + design-stage document remain schema-valid while unresolved. +7. **Typed numeric facts plus a separate `placeholders{}` map remain a possible v2 improvement** + and are explicitly **not** part of this proposal or its implementation. + +#### 6.6.2 `source_url` is provenance, not an execution endpoint (finalized) + +`market_data.source_url` records **where a human or auditor can verify the claim**. It is evidence +metadata. It is never fetched as part of generation, never used to call a vendor, and carries no +authentication. + +**Allowed in an EpisodePackage:** + +- a public market-data **provenance URL** in `market_data` source metadata (e.g. a publicly + viewable exchange or data-provider page documenting the observed move); +- a stable **repository-relative asset path** (`references.*.path`) or a canonical opaque + **asset ID** (`references.*.asset_id`). + +**Forbidden in an EpisodePackage, at every version:** + +- provider **API endpoints** (generation or market-data APIs); +- **signed URLs**; +- **temporary / expiring download URLs**; +- **account-specific URLs** (tenant, workspace, or user-scoped); +- **credentials, API keys, authorization headers, or tokens** in any field; +- **provider-native generation request payloads** (model names, sampler settings, vendor request + bodies). + +Provider adapters resolve `asset_id` and construct their own vendor payloads at call time; that +derived data lives in the production layer, never in the canonical package (§10 of ADR-016). + +**Non-advisory rule.** `market_data` records observations and criteria only. No field may express +a prediction or an assurance, and the fact card copy must not state that confirmation guarantees +future performance. A prohibited-phrase scan applies to `factual_note` and all `fact_card` copy. + +### 6.7 `references` + +| Field path | Type | Req. | Default | Stage | Purpose | +|---|---|---|---|---|---| +| `references.bible_version` | string (e.g. `"1.0"`) | optional | `null` | D/P | Canon version | +| `references.canon_decision` | string (e.g. `"ADR-015"`) | optional | `null` | D | Governing decision | +| `references.characters[]` | array | optional | `[]` | P | Character refs | +| `…characters[].character_key` | string | **required** | — | P | Must exist in `characters[]` | +| `…characters[].asset_id` | string | **required** | — | P | Stable asset ID | +| `…characters[].kind` | enum `identity\|episode` | optional | `"identity"` | P | Immutable identity vs episode-specific | +| `…characters[].view` | string (e.g. `front`, `three_quarter_left`) | optional | `null` | P | View hint | +| `…characters[].path` | repo-relative string | optional | `null` | P | Location, **never a provider URL** | +| `references.environments[]` | same shape keyed by `scene_key` | optional | `[]` | P | Environment refs | +| `references.props[]` | same shape keyed by `prop_key` | optional | `[]` | P | Prop refs | + +`asset_id` is opaque and stable; `path` is repo-relative. Provider-hosted URLs, buckets, and +credentials are prohibited (§10). + +### 6.8 `post_production` + +| Field path | Type | Req. | Default | Stage | Purpose | +|---|---|---|---|---|---| +| `post_production.overlays[]` | array | optional | `[]` | T/B | Overlay plan | +| `…overlays[].overlay_id` | string | **required** | — | T | Stable ID, referenced by `shots[].overlay_ids` | +| `…overlays[].type` | enum `chart_label\|subtitle\|notification\|fact_card\|disclaimer\|cta\|other` | **required** | — | T | Overlay kind | +| `…overlays[].shot_id` | string | optional | `null` | T | Target shot (null ⇒ episode-level) | +| `…overlays[].start_ms` / `end_ms` | int | optional | `null` | T | Timing within final runtime | +| `…overlays[].required` | bool | optional | `true` | B | Optional overlays may be dropped | +| `…overlays[].anchor` | enum `lower_safe\|upper_safe\|centre\|prop_local` | optional | `"lower_safe"` | T | Safe-area anchor | +| `…overlays[].localized[]` | array of `{language_tag, text}` | optional | `[]` | T | Copy | + +**Rule:** all readable financial text is an overlay. No generated image or video is required to +contain legible financial text. + +#### 6.8.1 Overlay timing coordinate system (finalized) + +There is exactly **one** timing coordinate system in v1.1: + +1. **All overlay `start_ms` / `end_ms` values are episode-absolute integer milliseconds.** +2. **Time zero is the first frame of generated footage** (the start of the shot with + `sequence = 1`). +3. **The appended fact card begins at `derived_generated_ms`** and ends at `derived_total_ms` + (EP001: 21 000 → 24 000 ms). +4. **Overlay timing may address either interval** — a moment inside generated footage, or a moment + inside the appended fact-card interval. Both are expressed in the same absolute scale. +5. **Bounds:** an overlay must satisfy `0 ≤ start_ms < end_ms ≤ derived_total_ms`. Starting before + 0 or ending after `derived_total_ms` is a hard error at post-production validation. +6. **`shot_id` is an association, not a clock.** A shot-specific overlay may carry `shot_id` for + grouping, diagnostics, and regeneration bookkeeping, but its **absolute timing remains + authoritative**. When both are present, absolute timing wins and the pair is cross-checked: the + overlay's window must fall inside that shot's absolute window (±150 ms tolerance), otherwise the + document is inconsistent and fails validation. +7. **`shot_id` must never introduce shot-relative offsets.** There is no `offset_ms`, + `relative_start_ms`, or equivalent field, and none may be added in v1.1. +8. **Subtitle cues use the identical system.** `localization.subtitle_tracks[].cues[].start_ms` / + `end_ms` are episode-absolute milliseconds with the same zero point and the same bounds rule, so + cues and overlays are directly comparable without conversion. + +**Why shot-relative timing was rejected for v1.1.** (a) It would create a *second* source of truth — +a cue would be defined by both a shot and an offset, and any shot re-time would silently move it, +which is exactly the duplicate-truth failure mode this proposal is built to avoid. (b) The fact card +is deliberately **not** a shot, so shot-relative timing has no way to address the 21 000–24 000 ms +interval without inventing a pseudo-shot. (c) Composition, subtitle formats (SRT/VTT), and NLE +tooling are all absolute-timeline based; emitting them from shot-relative data requires a +resolution step that can silently drift. (d) Validating "no overlap" and "within runtime" is trivial +in absolute time and awkward across shot boundaries. The cost is that re-timing a shot requires +recomputing dependent cue times — an explicit, testable operation, which is preferable to an +implicit one. + +### 6.9 Shot extensions + +| Field path | Type | Req. | Default | Stage | Purpose | v1 compat | +|---|---|---|---|---|---|---| +| `shots[].beginning_state` | string | optional | `""` | D/P | Start state for generation | new | +| `shots[].ending_state` | string | optional | `""` | D/P | End state for generation | new | +| `shots[].generation_risks[]` | array of strings | optional | `[]` | D/P | Known defect risks | new | +| `shots[].regeneration_fallback` | object | optional | `null` | P | Recovery-only alternative | new | +| `…regeneration_fallback.camera_movement` | CAS movement enum | optional | `null` | P | Fallback movement | reuses existing enum | +| `…regeneration_fallback.note` | string | optional | `""` | P | Why/when it applies | — | +| `shots[].overlay_ids[]` | array of strings | optional | `[]` | T | Links to `post_production.overlays[]` | new | + +**Camera rule, not a field.** "Exactly one dominant camera movement" is enforced at pre-render as a +*rule* on the existing `shots[].camera.movement` (must be present and single-valued for canonical +episodes). `regeneration_fallback` is explicitly **recovery-only** and never an equal option. + +## 7. Proposed JSON structure + +``` +EpisodePackage (v1.1) +├── (all v1 fields, unchanged) +├── output { aspect_ratio, width, height, fps, orientation, +│ generated_footage_ms, total_runtime_ms, safe_area{…} } +├── localization { spoken_language, required_publish_language_tags[], +│ subtitle_tracks[ { language_tag, is_primary, +│ rendering, cues[ {cue_id,start_ms,end_ms,text, +│ speaker_character_key?,shot_id?} ] } ] } +├── fact_card { duration_ms, placement, readable_text_in_post, +│ localized[ {language_tag, body[], disclaimer, cta?} ] } +├── market_data { instrument, timeframe, …facts…, source_name, source_url, +│ factual_note, data_lock{status, locked_at_utc} } +├── references { bible_version, canon_decision, characters[], environments[], props[] } +├── post_production { overlays[ {overlay_id,type,shot_id?,start_ms?,end_ms?, +│ required,anchor,localized[]} ] } +└── shots[] + beginning_state, ending_state, generation_risks[], + regeneration_fallback?, overlay_ids[] +``` + +## 8. Illustrative EP001-shaped example + +> **Design-stage example only — not production-ready and not publishable.** It intentionally +> retains `{{PLACEHOLDER}}` tokens and `data_lock.status = "unresolved"`, which by design **fail** +> pre-render and publish validation (§9). Shots are abbreviated; the full creative detail lives in +> the EP001 document. + +```json +{ + "schema_version": "1.1", + "episode_id": "CAS-EP001", + "title": "BTC Breaks Out — Bruno Celebrates Too Early", + "logline": "Bitcoin pushes above resistance, Bruno throws a party on the signal alone.", + "language": "en", + "source": { + "source_type": "news", + "headline": "BTC moves above {{RESISTANCE_LEVEL}}", + "summary": "Price moved above a prior resistance area; confirmation still outstanding.", + "source_url": null, + "published_at": "{{BREAKOUT_TIMESTAMP_UTC}}", + "factual_notes": "Initial breakout signal only. Not a confirmed breakout." + }, + "creative_direction": { + "format": "short_form_vertical", + "tone": "deadpan", + "target_duration_seconds": 24, + "visual_style": "premium stylized 3D", + "comedy_style": "personality collision", + "continuity_notes": "Bible v1 locked identities; The Burrow layout fixed." + }, + "characters": [ + { "character_key": "bruno_bull", "display_name": "Bruno Bull", "role": "momentum trader", + "description": "Anthropomorphic bull; chestnut-brown fur; forest-green rolled-sleeve shirt; mustard tie; black smartwatch on left wrist.", + "actor_key": "actor_bruno", "costume_key": "costume_bruno_office", + "voice_profile": "warm baritone, energetic", "continuity_notes": "Tallest. No jacket, hat, or glasses." }, + { "character_key": "boris_bear", "display_name": "Boris Bear", "role": "risk manager", + "description": "Anthropomorphic bear; charcoal-brown fur; burgundy knit vest over pale blue shirt; rectangular black reading glasses.", + "actor_key": "actor_boris", "costume_key": "costume_boris_office", + "voice_profile": "low, controlled", "continuity_notes": "Wider than Milo; red notebook and pen." }, + { "character_key": "milo_cat", "display_name": "Milo Cat", "role": "strategist", + "description": "Anthropomorphic burnt-orange tabby; three forehead stripes; dark teal turtleneck; silver Bitcoin pin on left chest.", + "actor_key": "actor_milo", "costume_key": "costume_milo_office", + "voice_profile": "smooth, understated", "continuity_notes": "Shortest. Matte black mug." } + ], + "assets": { + "actors": [ { "actor_key": "actor_bruno", "display_name": "Bruno", "description": "Bull identity plate." }, + { "actor_key": "actor_boris", "display_name": "Boris", "description": "Bear identity plate." }, + { "actor_key": "actor_milo", "display_name": "Milo", "description": "Cat identity plate." } ], + "scenes": [ { "scene_key": "the_burrow", "display_name": "The Burrow", "description": "Trading studio: curved desk, wall BTC chart, coffee station, glass wall." } ], + "props": [ { "prop_key": "wall_btc_chart", "display_name": "Wall BTC chart", "description": "Abstract, textless chart plate." }, + { "prop_key": "milo_phone", "display_name": "Phone", "description": "Supporting screen prop; glow only, no legible text." } ], + "costumes":[ { "costume_key": "costume_bruno_office", "display_name": "Bruno office", "description": "Forest-green shirt, mustard tie." }, + { "costume_key": "costume_boris_office", "display_name": "Boris office", "description": "Burgundy vest, pale blue shirt." }, + { "costume_key": "costume_milo_office", "display_name": "Milo office", "description": "Dark teal turtleneck." } ] + }, + "shots": [ + { "shot_id": "SC01", "sequence": 1, "title": "The premature toast", "duration_seconds": 3.0, + "script_excerpt": "Bruno bursts in as the alert flares.", + "camera": { "shot_type": "MS", "angle": "EYE_LEVEL", "movement": "DOLLY_IN" }, + "action": "Bruno shoulders through the doorway, arms rising.", + "dialogue": [ { "order": 1, "character_key": "bruno_bull", "text": "Breakout! We are so back!", "line_mode": "DIALOGUE" } ], + "character_keys": ["bruno_bull", "boris_bear"], "scene_key": "the_burrow", + "prop_keys": ["wall_btc_chart"], "costume_keys": ["costume_bruno_office"], + "image_prompt": "", "video_prompt": "", "negative_prompt": "", + "continuity_notes": "Forest-green shirt; watch on left wrist; horns symmetrical.", + "metadata": { "beat": "hook" }, + "beginning_state": "Door half-open; chart line crossing the level.", + "ending_state": "Bruno fully in frame, arms up; green accent lit.", + "generation_risks": ["extra or asymmetric horns", "jacket appearing", "legible chart text"], + "overlay_ids": ["ov_chart_label_01"] }, + { "shot_id": "SC02", "sequence": 2, "title": "Confirmation, please", "duration_seconds": 7.0, + "script_excerpt": "Boris blocks the celebration.", + "camera": { "shot_type": "MCU", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Boris raises a flat paw, clutching the red notebook.", + "dialogue": [ { "order": 1, "character_key": "boris_bear", "text": "The candle hasn't closed yet.", "line_mode": "DIALOGUE" } ], + "character_keys": ["boris_bear", "bruno_bull"], "scene_key": "the_burrow", + "prop_keys": [], "costume_keys": ["costume_boris_office"], + "image_prompt": "", "video_prompt": "", "negative_prompt": "", + "continuity_notes": "Glasses present; vest burgundy; ears small and rounded.", + "metadata": { "beat": "conflict" }, + "beginning_state": "Boris mid-turn from his monitor.", + "ending_state": "Paw up, notebook chest-high.", + "generation_risks": ["glasses disappearing", "vest colour drift"], + "overlay_ids": [] }, + { "shot_id": "SC03", "sequence": 3, "title": "The dip", "duration_seconds": 6.5, + "script_excerpt": "The chart dips; Bruno freezes.", + "camera": { "shot_type": "MLS", "angle": "EYE_LEVEL", "movement": "HANDHELD" }, + "action": "Bruno freezes mid-celebration; papers hang in the air.", + "dialogue": [ { "order": 1, "character_key": "bruno_bull", "text": "It's still green… right?", "line_mode": "DIALOGUE" } ], + "character_keys": ["bruno_bull", "boris_bear"], "scene_key": "the_burrow", + "prop_keys": ["wall_btc_chart"], "costume_keys": [], + "image_prompt": "", "video_prompt": "", "negative_prompt": "", + "continuity_notes": "Resistance line at the same screen height as SC01.", + "metadata": { "beat": "escalation" }, + "beginning_state": "Celebration at maximum; chart at local high.", + "ending_state": "Bruno statue-still; chart lower by {{PULLBACK_PCT}}.", + "generation_risks": ["duplicate characters", "full-frame red wash", "wardrobe change"], + "overlay_ids": ["ov_chart_label_02"] }, + { "shot_id": "SC04", "sequence": 4, "title": "Before the close", "duration_seconds": 4.5, + "script_excerpt": "Milo lowers his mug and reveals the delivery.", + "camera": { "shot_type": "CU", "angle": "EYE_LEVEL", "movement": "PAN" }, + "action": "Milo lowers the mug, glances at his phone, slow blink.", + "dialogue": [ { "order": 1, "character_key": "milo_cat", "text": "Your confetti arrives before candle close.", "line_mode": "DIALOGUE" } ], + "character_keys": ["milo_cat", "bruno_bull", "boris_bear"], "scene_key": "the_burrow", + "prop_keys": ["milo_phone"], "costume_keys": ["costume_milo_office"], + "image_prompt": "", "video_prompt": "", "negative_prompt": "", + "continuity_notes": "Teal turtleneck; pin on left chest; phone glow only.", + "metadata": { "beat": "punchline" }, + "beginning_state": "Mug at lips; phone face-up, screen glow only.", + "ending_state": "Mug at chest height; gaze level; tail settled.", + "generation_risks": ["legible phone text", "stripe or eye-colour drift", "glasses on Milo"], + "regeneration_fallback": { "camera_movement": "STATIC", "note": "Recovery only if identity drifts during the pan. Not an equal option." }, + "overlay_ids": ["ov_phone_notification"] } + ], + "metadata": { "created_at": null, "generator": "cas-design", "model": "", "prompt_version": "ep001-design-v1.1", "tags": ["ep001", "design-stage"] }, + + "output": { + "aspect_ratio": "9:16", "width": 1080, "height": 1920, "fps": 30, "orientation": "vertical", + "generated_footage_ms": 21000, "total_runtime_ms": 24000, + "safe_area": { "subtitle_bottom_pct": 18, "margin_pct": 6 } + }, + "localization": { + "spoken_language": "en", + "required_publish_language_tags": ["zh-Hant"], + "subtitle_tracks": [ + { "language_tag": "zh-Hant", "is_primary": true, "rendering": "post_production", + "cues": [ + { "cue_id": "c1", "start_ms": 400, "end_ms": 2000, "text": "突破了!我們回來了!", "speaker_character_key": "bruno_bull", "shot_id": "SC01" }, + { "cue_id": "c2", "start_ms": 3400, "end_ms": 5400, "text": "這根K棒還沒收。", "speaker_character_key": "boris_bear", "shot_id": "SC02" }, + { "cue_id": "c3", "start_ms": 11000, "end_ms": 12800, "text": "還是綠的……對吧?", "speaker_character_key": "bruno_bull", "shot_id": "SC03" }, + { "cue_id": "c4", "start_ms": 17200, "end_ms": 19600, "text": "你的彩帶會比收盤先到。", "speaker_character_key": "milo_cat", "shot_id": "SC04" } + ] } + ] + }, + "fact_card": { + "duration_ms": 3000, "placement": "append_after_shots", "readable_text_in_post": true, + "localized": [ + { "language_tag": "en", + "body": [ + "Moving above {{RESISTANCE_LEVEL}} is the initial breakout signal — not a confirmed breakout.", + "Some traders wait for the {{TIMEFRAME}} candle to close above it and follow through.", + "Confirmation criteria don't guarantee future performance." + ], + "disclaimer": "For education and entertainment, not financial advice.", + "cta": null }, + { "language_tag": "zh-Hant", + "body": [ + "價格站上 {{RESISTANCE_LEVEL}} 只是初步突破訊號,不等於已確認突破。", + "部分交易者會等 {{TIMEFRAME}} K棒收在其上並延續。", + "確認條件並不保證未來表現。" + ], + "disclaimer": "僅供教育與娛樂,非投資建議。", + "cta": null } + ] + }, + "market_data": { + "instrument": "BTC-USD", "timeframe": "{{TIMEFRAME}}", + "resistance_level": "{{RESISTANCE_LEVEL}}", "price": "{{BTC_PRICE}}", + "price_move_pct": "{{PRICE_MOVE_PCT}}", "pullback_pct": "{{PULLBACK_PCT}}", + "event_timestamp_utc": "{{BREAKOUT_TIMESTAMP_UTC}}", + "candle_close_timestamp_utc": "{{CANDLE_CLOSE_TIME_UTC}}", + "as_of_utc": "{{AS_OF_UTC}}", + "source_name": "{{DATA_SOURCE}}", "source_url": null, + "factual_note": "Price moved above the level; confirmation outstanding at capture time.", + "ath_context": null, + "data_lock": { "status": "unresolved", "locked_at_utc": null } + }, + "references": { + "bible_version": "1.0", "canon_decision": "ADR-015", + "characters": [ + { "character_key": "bruno_bull", "asset_id": "cas/bruno/identity/front", "kind": "identity", "view": "front", "path": null }, + { "character_key": "boris_bear", "asset_id": "cas/boris/identity/front", "kind": "identity", "view": "front", "path": null }, + { "character_key": "milo_cat", "asset_id": "cas/milo/identity/front", "kind": "identity", "view": "front", "path": null } + ], + "environments": [ { "scene_key": "the_burrow", "asset_id": "cas/env/the_burrow/master_wide", "kind": "identity", "view": null, "path": null } ], + "props": [ { "prop_key": "wall_btc_chart", "asset_id": "cas/prop/wall_chart/textless", "kind": "identity", "view": null, "path": null } ] + }, + "post_production": { + "overlays": [ + { "overlay_id": "ov_chart_label_01", "type": "chart_label", "shot_id": "SC01", "start_ms": 600, "end_ms": 3000, "required": false, "anchor": "upper_safe", + "localized": [ { "language_tag": "en", "text": "{{RESISTANCE_LEVEL}}" } ] }, + { "overlay_id": "ov_chart_label_02", "type": "chart_label", "shot_id": "SC03", "start_ms": 10200, "end_ms": 13000, "required": false, "anchor": "upper_safe", + "localized": [ { "language_tag": "en", "text": "-{{PULLBACK_PCT}}" } ] }, + { "overlay_id": "ov_phone_notification", "type": "notification", "shot_id": "SC04", "start_ms": 17000, "end_ms": 19000, "required": false, "anchor": "prop_local", + "localized": [ { "language_tag": "en", "text": "Delivery arriving" }, { "language_tag": "zh-Hant", "text": "外送即將送達" } ] }, + { "overlay_id": "ov_fact_card", "type": "fact_card", "shot_id": null, "start_ms": 21000, "end_ms": 24000, "required": true, "anchor": "centre", "localized": [] }, + { "overlay_id": "ov_disclaimer", "type": "disclaimer", "shot_id": null, "start_ms": 21000, "end_ms": 24000, "required": true, "anchor": "lower_safe", "localized": [] } + ] + } +} +``` + +## 9. Validation rules by lifecycle stage + +### 9.1 Stages + +| Stage | When | Placeholders allowed? | Purpose | +|---|---|---|---| +| **Design** | authoring | **yes** | Structure and references are coherent | +| **Pre-render / data-lock** | before any generation | **no** | Facts resolved, timing consistent, format fixed | +| **Provider-input** | per provider call | n/a | Required prompt/reference inputs exist | +| **Post-production** | before compositing | n/a | Overlays, subtitles, card are placeable | +| **Publish** | before release | **no** | Legal/brand gates satisfied | + +### 9.2 Timing model and mismatch rules (authoritative) + +**Authoritative formulas (implementation rule).** + +``` +derived_generated_ms = sum( round_half_up(shots[i].duration_seconds * 1000) ) # for all shots +derived_total_ms = derived_generated_ms + + fact_card.duration_ms # only when placement == "append_after_shots" +``` + +- **Derived values are authoritative.** They define the episode's real timing. +- `output.generated_footage_ms` and `output.total_runtime_ms` are **optional assertions only**. + They **never** override shot or fact-card timing; they exist to catch authoring mistakes. +- **A mismatch greater than 50 ms between an assertion and its derived value is a hard error at + pre-render.** Within ±50 ms the assertion is accepted and the derived value is still used. +- `creative_direction.target_duration_seconds` remains **non-authoritative author intent**. A + difference from `derived_total_ms` produces a **warning only**, never an error, and never + participates in any calculation. +- **One rounding strategy, used everywhere: `round_half_up`** — multiply by 1000, then round half + away from zero to an integer millisecond (e.g. `3.0005 s → 3001 ms`, `2.9995 s → 3000 ms`). + Implementations must apply it via an explicit helper and **must not** rely on Python's built-in + `round()`, which uses banker's rounding and would produce different results on exact `.5` cases. + The same helper is used for shot durations, assertion comparison, and any derived cue arithmetic. +- When `placement == "overlay_tail"` the fact card is composited over the tail of existing footage + and contributes **no** additional time: `derived_total_ms == derived_generated_ms`. +- Publish requires `15000 ≤ derived_total_ms ≤ 30000` for canonical episodes (Bible / ADR-015). +- EP001: derived_generated = 21 000 ms, fact card 3 000 ms, derived_total = **24 000 ms** ✓. + +### 9.3 Stage rules + +**Design (D).** `schema_version ∈ {"1.0","1.1"}`; all v1 rules; every `overlay_ids` entry resolves +to an overlay; `speaker_character_key` / `shot_id` in cues resolve; `references.*` keys resolve to +declared characters/scenes/props; fact card is not present in `shots[]`. + +**Pre-render / data-lock (L).** All of D, plus: **no `{{…}}` token anywhere** in `market_data`, +`fact_card`, or overlay copy; `market_data.data_lock.status == "locked"` with `locked_at_utc`; +`as_of_utc`, `source_name`, `factual_note` present and non-empty; **locked market facts parse per +§6.6.1** (no empty/whitespace-only/NaN-like/`null`-where-not-allowed values); timing assertions +match (§9.2); `output` resolved (explicitly or by default); for canonical episodes every shot has +exactly one `camera.movement`; subtitle cue rules (episode-absolute ms per §6.8.1): `start_ms ≥ 0`, +`end_ms > start_ms` (no zero/negative duration), cues sorted and **non-overlapping within a track**, +`end_ms ≤ derived_total_ms`, and if `shot_id` is set the cue lies within that shot's absolute window +(±150 ms lead-in tolerance). + +**Provider-input (P).** Each shot has `beginning_state`/`ending_state` (or documented absence) and +resolvable `references` for every character it uses; **no forbidden URL or credential of any kind +appears anywhere in the package (§6.6.2)** — a public provenance `source_url` is permitted. + +**Post-production (T).** Every `required: true` overlay has a placement and, where copy is needed, +localized text; **every tag in `localization.required_publish_language_tags[]` has a matching, +non-empty subtitle track (§6.4.1)**; a declared-but-empty track is invalid from this stage onward; +fact card copy present for the publish languages; overlay bounds satisfy +`0 ≤ start_ms < end_ms ≤ derived_total_ms` and any `shot_id` cross-check passes; overlays fit their +safe-area anchor. + +**Publish (B).** All of L and T, plus: every required publish language has a complete subtitle +track (one cue per dialogue line); `fact_card.localized[].disclaimer` non-empty for every published +language; prohibited-phrase scan passes on dialogue, `factual_note`, and all overlay/card copy; +runtime in range; **the unresolved-placeholder check is repeated** (data lock is not trusted as a +permanent guarantee, since copy can change after locking). + +*Legacy note:* a `"1.0"` document declares no required publish language, so stages T and B impose +no subtitle obligation on it. + +## 10. Backward-compatibility matrix + +### 10.1 Version parsing policy (finalized) + +1. **`schema_version` remains the existing version discriminator.** No second version field is + introduced at any layer. +2. **A v1 package keeps its existing version value and semantics** — `"1.0"`, meaning exactly what + it means today. +3. **A v1.1 package declares `"1.1"`.** +4. **The v1.1-capable parser explicitly accepts a known set** — `SUPPORTED_SCHEMA_VERSIONS = + {"1.0", "1.1"}` — by explicit membership test, never by range, prefix, or "greater-or-equal" + comparison. +5. **Missing `schema_version` follows the existing v1 behaviour discovered in the implementation:** + it is a **required field**, so an absent value is a Pydantic missing-field validation error. No + fallback, no default, and no inferred version is introduced — this proposal invents nothing here. +6. **Unknown versions fail explicitly** with a typed `UnsupportedSchemaVersionError` (surfaced as + HTTP 422). They are never coerced, clamped, or treated as the newest supported version. +7. **No version is silently upgraded during parsing.** Reading a `"1.0"` document never rewrites its + `schema_version`, never injects the new optional objects into the source document, and never + re-serializes it as `"1.1"`. Defaults exist only in the derived in-memory view (§6.1). +8. **Existing v1 payload hashes are never rewritten or recomputed** merely because a v1.1-capable + parser read the package. `canonical_payload_hash` hashes the fields actually present, so a + `"1.0"` document keeps a byte-identical hash and existing `cas_import_ledger` rows stay valid. +9. **Definition of backward compatibility used here:** v1 documents remain accepted by the upgraded + implementation. It does **not** mean v1.1 documents are accepted by unmodified v1 code — they + are not, and cannot be (§10.2). + +### 10.2 Matrix + +| Scenario | Result | Notes | +|---|---|---| +| v1 document → v1 parser | ✅ valid | unchanged | +| v1 document → v1.1 parser | ✅ valid | all new fields optional; defaults applied in the derived view only | +| v1.1 document **without** new fields → v1 parser | ❌ rejected | `schema_version == "1.1"` fails the strict equality check | +| v1.1 document **with** new fields → v1 parser | ❌ rejected | `extra="forbid"` on every model | +| v1.1 document → v1.1 parser | ✅ valid | — | +| Existing v1 sample & 68 CAS tests after implementation | ✅ unaffected | sample stays `"1.0"`; its payload hash is unchanged | + +**Honest statement of direction.** Compatibility is **backward, not forward**: old documents keep +working under new code; new documents do not work under old code. This is inherent to +`extra="forbid"` + strict version equality and is accepted deliberately (see ADR-016, "Alternatives +considered") rather than worked around by loosening `extra`, which would silently swallow typos. + +## 11. Migration and rollout plan + +1. **R0 — approval.** ADR-016 moves Proposed → Accepted. +2. **R1 — parser.** Accept `{"1.0","1.1"}`; add optional models; keep `SCHEMA_VERSION = "1.0"` as + the *minimum* and add `SUPPORTED_SCHEMA_VERSIONS`; raise a typed + `UnsupportedSchemaVersionError` (mapped to HTTP 422) for anything else. +3. **R2 — validators.** Implement the five stages as separate callables so design-stage documents + are never blocked by data-lock rules. +4. **R3 — canonical sample.** **Add** `samples/cas/ep001_episode.json` (v1.1). **Do not edit** + `samples/cas/demo_episode.json` — Sprint 4 tests and the ledger payload hash depend on its + exact bytes. +5. **R4 — consumers.** Importer and production orchestrator read the new fields *opportunistically* + (absent ⇒ current behaviour). No behavioural change for v1 packages. +6. **R5 — Prompt Builder v1** consumes `beginning_state`, `ending_state`, `references`, and + `generation_risks` (separate scoped sprint; not part of this proposal). + +No data migration and no backfill: existing stored ledger rows keep `schema_version = "1.0"` and +remain correct. + +## 12. Implementation impact by component + +| Component | Impact | Notes | +|---|---|---| +| `schemas/episode_package.py` | **Additive** | six optional root models + five optional shot fields | +| `domain/episode_package.py` | Small | add `SUPPORTED_SCHEMA_VERSIONS`; keep `SCHEMA_VERSION` | +| `application/hashing.py` | **None** | canonical hash already covers whatever fields exist | +| `application/import_episode.py` | Optional | may map subtitles/overlays later; v1 path unchanged | +| `domain/import_ledger.py` + `sql/009` | **None** | `schema_version VARCHAR(16)` already fits `"1.1"` | +| `production/*` (Sprint 4) | Optional | orchestrator may read `output`/`fact_card` later | +| `api/`, `schemas/production.py` | Minor | request models accept v1.1 packages once the parser does | +| CLI | **None** | file-driven | +| **Database** | **No migration required** | see below | + +**Why no DB migration.** Inspection shows EpisodePackage is **never column-mapped**: the ledger +stores only `payload_hash` (SHA-256) and `schema_version VARCHAR(16)`; production stores *derived* +prompts and artifact rows. Adding optional JSON fields therefore changes no table. A migration +would only become necessary if a future sprint decided to persist the raw package or index new +fields — which this proposal does not do. + +## 13. Testing strategy + +- **Compatibility:** the existing v1 sample validates unchanged under the v1.1 parser; its + canonical payload hash is byte-identical; all 68 CAS tests stay green. +- **Version handling:** `"1.0"` accepted; `"1.1"` accepted; `"2.0"`/`""`/absent rejected with the + typed error. +- **Subtitles:** negative/zero-duration cue rejected; overlapping cues in one track rejected; + overlapping cues across *different* tracks accepted; cue beyond runtime rejected; unknown + `speaker_character_key` or `shot_id` rejected. +- **Timing:** declared/derived mismatch > 50 ms rejected at pre-render; `target_duration_seconds` + discrepancy warns only; EP001 sums to exactly 24 000 ms. +- **Fact card:** card never counted as a shot; four shots remain four; missing disclaimer fails + publish, not design. +- **Data lock:** placeholder-bearing document passes design and **fails** pre-render and publish; + same document with values and `status = "locked"` passes. +- **Provider neutrality:** a package containing a URL/credential-shaped provider field is rejected. +- **Golden:** EP001 v1.1 example round-trips (parse → dump → parse) byte-stably. + +## 14. Rejected alternatives + +| Alternative | Why rejected | +|---|---| +| Full **v2** redesign | Violates the minimal-change instruction; breaks the importer, ledger hashes, and 68 tests for no EP001 benefit. | +| Relax `extra="ignore"` for forward compatibility | Would silently swallow typos and contract drift — the opposite of the strictness that has caught real errors. | +| Reuse `shots[].metadata` (free dict) for subtitles/fact card | Untyped, unvalidatable, and creates a shadow contract; timing errors would reach rendering. | +| Reinterpret `creative_direction.target_duration_seconds` as authoritative runtime | Silent semantic change to an existing field; also an int, so it cannot express 24.0 s + ms precision. | +| Add a **new** `package_version` field beside `schema_version` | Duplicate source of truth for versioning. | +| Store subtitles as one blob (SRT/VTT string) | Not queryable or validatable; cue-level rules become impossible. | +| Make the fact card a fifth shot | Explicitly forbidden: it would enter generation and put readable financial text into AI imagery. | +| Embed provider URLs/credentials for references | Security and neutrality violation; adapters resolve `asset_id` themselves. | + +## 15. Open questions and explicit non-goals + +**Resolved in this clarification pass.** + +1. ~~Subtitle requirement scope~~ → **Resolved (§6.4.1).** Conditionally required via + `localization.required_publish_language_tags[]`; never universally required; legacy v1 exempt. +2. ~~Market-fact representation~~ → **Resolved (§6.6.1).** Placeholder-capable strings retained as + a deliberate minimal-v1.1 compromise; typed facts + `placeholders{}` map deferred to a possible + v2 and explicitly out of scope. +3. ~~Overlay/cue coordinate system~~ → **Resolved (§6.8.1).** Episode-absolute integer + milliseconds, zero at the first frame of generated footage; shot-relative timing rejected. +4. ~~`source_url` vs provider URL~~ → **Resolved (§6.6.2).** `source_url` is provenance evidence, + never an execution endpoint; explicit allowed/forbidden lists. +5. ~~Rounding strategy~~ → **Resolved (§9.2).** One documented `round_half_up` helper everywhere. + +**Resolved in Step 4.6 (governance closure).** + +6. **Reference-asset validation responsibility — split by layer.** + - *Canonical package validation owns:* reference **key consistency** (every + `references.*` key resolves to a declared character/scene/prop), **required reference presence + for assets actually used by generation inputs** (a character used by a shot must have a + reference when `references` is declared), **shape** of a stable repository-relative `path` or + canonical `asset_id`, and **rejection of provider-specific or unsafe references** (§6.6.2). + - *Provider-input / orchestration preflight owns:* checking that the referenced asset **actually + exists** in the configured asset registry or repository, and **resolving** it into + provider-ready input. + - **Schema parsing performs no filesystem, registry, or network I/O** — ever. + - No asset-registry abstraction exists in the repository today, so existence checking is + recorded as a **later provider-integration preflight requirement**. No registry is invented + here. +7. **Re-timing responsibility — post-production assembly owns it.** Any change to shot order or + duration invalidates downstream *absolute* subtitle and overlay timing. The **post-production + assembly stage** owns recomputing cues and overlays. After re-timing, the package **must be + revalidated from `pre_render_data_lock` through `publish`** before it can be published. + Automatic re-timing is **not** implemented in v1.1; validation deliberately fails on stale + timing so the correction is explicit and testable. + +**Non-goals.** No v2; no changes to v1 field names/types/semantics; no typed market-data redesign; +no Prompt Builder v1; no real providers; no EP001 production JSON; no DB schema change; no frontend +work; no change to the importer contract or the Sprint 4 production pipeline behaviour. diff --git a/docs/crypto-animal-studio/episodes/EP001-btc-breaks-out-bruno-celebrates-too-early.md b/docs/crypto-animal-studio/episodes/EP001-btc-breaks-out-bruno-celebrates-too-early.md new file mode 100644 index 00000000..dd7485c3 --- /dev/null +++ b/docs/crypto-animal-studio/episodes/EP001-btc-breaks-out-bruno-celebrates-too-early.md @@ -0,0 +1,472 @@ +# CAS-EP001 — BTC Breaks Out: Bruno Celebrates Too Early + +> Creative specification only. No code, schema, sample, or provider work is authorized by this +> document. Canonical sources: [Crypto Animal Bible v1](../Crypto_Animal_Bible_v1.md), +> [ADR-015](../../adr/ADR-015-crypto-animal-bible-v1-canon.md), +> [gap report](../bible-v1-implementation-gap-report.md). +> +> All time-sensitive market values appear as `{{PLACEHOLDER}}` and **must** be supplied from a +> real source before production (see §3). No live price is invented here, and the story works +> unchanged once real values are inserted. + +--- + +## 1. Episode identity + +| Field | Value | +|---|---| +| Episode ID | **CAS-EP001** | +| Working title | BTC Breaks Out — Bruno Celebrates Too Early | +| Logline | Bitcoin pushes above resistance, Bruno throws a party on the signal alone; Boris points out the candle hasn't closed; the chart dips, and Milo reveals the confetti will land before the close does. | +| Target runtime | **24.0 seconds** (hard range 15–30 s) | +| Format | 9:16 vertical, premium stylized 3D | +| Spoken language | English | +| Subtitle language | Traditional Chinese (zh-Hant) | +| Canonical cast | Bruno Bull, Boris Bear, Milo Cat (launch trio only) | +| World | Block Street | +| Primary location | The Burrow | +| Generated footage | 4 cinematic shots, 0.0–21.0 s | +| Post-production card | Fact card + disclaimer, 21.0–24.0 s (not AI-generated) | + +## 2. Creative objective + +**Market concept viewers should understand.** Price moving above a resistance level is the +**initial breakout signal** — it is not the same thing as a *confirmed* breakout. Some traders +treat the move as confirmed only after a candle closes above the level and price follows through. +Those criteria are how the move is commonly judged; they do not guarantee future performance. + +**Emotional experience.** `surprise → euphoria → doubt → renewed chaos → calm punchline` +(Bible §12). The viewer feels the pull of early celebration, then the small drop in the stomach +when the move is questioned, then relief through comedy rather than through advice. + +**Memorable character joke.** Bruno ordered the confetti off the initial signal, so the delivery +will arrive **before** the candle that would confirm the move even closes. Boris is right about +confirmation and still overreacts. Milo says almost nothing and wins the scene. + +**Why this is the right first episode.** It is the Bible's own first-episode baseline (§12); it +introduces each character's core function in a single situation (accelerator / risk / foresight); +it needs one set, three characters, and no supporting cast; it teaches one genuinely useful +market idea without giving advice; and it survives price substitution, so it can be produced on +any day a real breakout occurs. + +## 3. Factual boundary + +**Plain-language definitions (non-advisory).** + +- *Resistance level* — a price area, here `{{RESISTANCE_LEVEL}}`, where selling has previously + slowed or reversed advances. +- *Breakout signal (the opening event of this episode)* — price moves **above** + `{{RESISTANCE_LEVEL}}`. This is the initial signal and an observation of what happened; it is + not yet a confirmed breakout. +- *Confirmation (one common stricter reading)* — price **closes** above the level on the chosen + timeframe (`{{TIMEFRAME}}`, close at `{{CANDLE_CLOSE_TIME_UTC}}`) and then shows follow-through, + i.e. continues to hold or extend above it. Candle close and follow-through **may be used** as + confirmation criteria; meeting them does not guarantee future performance. +- *Pullback* — price moving back toward or below the level after an initial move above it. + +**Required distinction, stated in the episode.** The opening event is price moving above +`{{RESISTANCE_LEVEL}}` — the initial breakout signal. Bruno reacts to that signal alone. Boris +points out the candle has not closed, i.e. confirmation is still outstanding. That is the entire +educational payload; the script never asserts that the breakout was confirmed and never resolves +whether it ultimately held. + +**Explicit non-claims.** Candle close and follow-through are **descriptions of how some traders +judge a move**, not a method that guarantees future performance. Nothing in this episode predicts +direction, recommends any action, or implies a reliable outcome. No "guaranteed", "risk-free", +"buy", or "sell" language appears anywhere, spoken or on-screen. + +**Placeholders (all must be replaced with sourced values before production).** + +| Placeholder | Meaning | +|---|---| +| `{{BTC_PRICE}}` | Price shown at the moment of the hook | +| `{{RESISTANCE_LEVEL}}` | The resistance area being cleared | +| `{{PRICE_MOVE_PCT}}` | Percentage move for the period | +| `{{PULLBACK_PCT}}` | Size of the brief pullback in Shot 3 | +| `{{TIMEFRAME}}` | Candle timeframe used for confirmation (e.g. 4h, 1D) | +| `{{CANDLE_CLOSE_TIME_UTC}}` | Close time of the relevant candle | +| `{{BREAKOUT_TIMESTAMP_UTC}}` | Timestamp of the observed move | +| `{{DATA_SOURCE}}` | Named market-data source | +| `{{ATH_CONTEXT}}` | Optional: prior high context, only if factual | + +**Metadata to supply before production** (belongs in EpisodePackage `source`, per Bible §7 — not +in spoken dialogue): `{{DATA_SOURCE}}`, `{{BREAKOUT_TIMESTAMP_UTC}}`, `{{TIMEFRAME}}`, +`{{CANDLE_CLOSE_TIME_UTC}}`, `{{RESISTANCE_LEVEL}}`, `{{BTC_PRICE}}`, `{{PRICE_MOVE_PCT}}`, +`{{PULLBACK_PCT}}`, source URL, and a one-line factual note confirming the move was observed. + +## 4. Beat sheet with exact timing + +| Beat | Time | Duration | Content | +|---|---|---|---| +| Hook | 0.0–3.0 | 3.0 s | Price moves above `{{RESISTANCE_LEVEL}}` — the initial breakout signal; the alert flares and Bruno bursts in mid-celebration. | +| Conflict | 3.0–10.0 | 7.0 s | Boris blocks the celebration: the candle hasn't closed, so confirmation is outstanding. | +| Escalation | 10.0–16.5 | 6.5 s | Chart dips `{{PULLBACK_PCT}}`; Bruno freezes mid-air; Boris overreacts. | +| Punchline | 16.5–21.0 | 4.5 s | Milo sips coffee and reveals the confetti will arrive before the candle closes. | +| Fact card / disclaimer | 21.0–24.0 | 3.0 s | Post-production card: breakout signal vs confirmation + "Not financial advice". | +| **Total** | | **24.0 s** | Within the 15–30 s canonical range. | + +## 5. Four-shot storyboard specification + +Global: 9:16 (1080×1920 target), central safe area for faces and critical action, one dominant +camera movement per shot, cut on action or reaction. Palette anchors: night navy `#0B1220`, +steel `#6F8199`, market green `#16C784`, risk red `#EA3943`, teal `#18A7A0`, amber `#F5B942`. +Red/green never carry meaning alone — character reaction and shape carry it too. + +### Shot 1 — Hook + +| Field | Specification | +|---|---| +| Shot number | 1 of 4 | +| Time / duration | 0.0–3.0 s (3.0 s) | +| Story purpose | Establish The Burrow, the initial breakout signal (price moving above `{{RESISTANCE_LEVEL}}`), and Bruno's impulsive optimism in one image. | +| Visible characters | Bruno (primary, entering); Boris seated at his workstation (background left, partly visible); Milo not yet visible. | +| Composition & safe area | Vertical thirds: wall-sized BTC chart occupies the upper third; Bruno's head/shoulders centred in the middle third; desk edge in the lower third. Bruno's face inside the central safe area; chart glow may bleed to frame edges. | +| Character action | Bruno shoulders through the doorway, both arms already rising, tie swinging. | +| Facial expression | Wide-eyed delight, open mouth mid-shout, thick eyebrows raised high. | +| Beginning state | Door half-open, Bruno's silhouette entering frame right; chart line just crossing `{{RESISTANCE_LEVEL}}`. | +| Ending state | Bruno fully in frame, arms up, hooves planted; chart alert glowing green. | +| Camera framing | Medium-wide, slight low angle to make Bruno read as tallest. | +| Dominant camera movement | **Push-in** (gentle, toward Bruno's chest/face). | +| Lighting & market accent | Cool office ambience `#6F8199` + warm amber key on Bruno's face; **green accent** `#16C784` as secondary light from the chart — accent only, never a full-frame neon wash. | +| Environment & props | Doorway (frame right), curved trading desk, wall BTC chart with a rising line crossing a horizontal level, Boris's red notebook visible on desk. | +| English dialogue | Bruno: "Breakout! We are so back!" | +| zh-Hant subtitle | 「突破了!我們回來了!」 | +| Sound effects | Door swing, single bright alert chime, hoof impacts with weight. | +| Music direction | Rising synth stab entering on the alert; energetic but not chaotic. | +| Transition out | Hard cut on Bruno's arms reaching apex → Shot 2. | +| Continuity requirements | Forest-green rolled-sleeve shirt, mustard tie loosened, black smartwatch on **left** wrist, ivory horns with dark tips curving outward then upward. Chart level line must sit at the same screen height in Shots 1, 3. | +| Generation risks | Extra or asymmetric horns; jacket or hat appearing; smartwatch drifting to right wrist; readable text baked into the chart; doorway geometry inconsistent with Shot 4. | + +### Shot 2 — Conflict + +| Field | Specification | +|---|---| +| Shot number | 2 of 4 | +| Time / duration | 3.0–10.0 s (7.0 s) | +| Story purpose | Boris blocks the celebration with the episode's actual market point. | +| Visible characters | Boris (primary, seated/half-rising); Bruno's arm and shoulder intruding from frame right. | +| Composition & safe area | Boris centred slightly left, occupying the middle third; his red notebook raised into the lower-middle third; Bruno's forearm crossing the right edge to keep both characters connected. Boris's glasses and eyes inside the safe area. | +| Character action | Boris raises a flat paw to stop Bruno, other paw clutching the red notebook; ears flick back at the alert. | +| Facial expression | Tight-lipped, brows drawn, steel-blue eyes narrowed behind rectangular glasses; controlled stress, not panic yet. | +| Beginning state | Boris mid-turn from his monitor, notebook at desk level. | +| Ending state | Paw up, notebook raised chest-high, glasses caught in the chart's green light. | +| Camera framing | Medium close-up on Boris, Bruno's limb as foreground framing. | +| Dominant camera movement | **Rack focus** from Bruno's foreground arm to Boris's face. | +| Lighting & market accent | Cool base; green accent still present but weaker; warm key preserves Boris's fur colour so charcoal-brown never reads black. | +| Environment & props | Boris's workstation, monitor with unreadable placeholder chart, red risk notebook, red pen, coffee station soft-focus behind. | +| English dialogue | Boris: "The candle hasn't closed yet." | +| zh-Hant subtitle | 「這根K棒還沒收。」 | +| Sound effects | Notebook flap, chair creak, subtle ear-flick whoosh, low UI blip. | +| Music direction | Pull energy back; hold a single tense sustained note under the line. | +| Transition out | Cut on Boris's paw reaching full extension → Shot 3. | +| Continuity requirements | Burgundy knit vest over pale blue dress shirt, navy trousers, rectangular black reading glasses, red notebook **and** red pen present. Boris reads clearly wider than Milo and slightly shorter than Bruno. | +| Generation risks | Glasses vanishing or changing shape; vest colour drifting toward brown/red; ears rendered large/pointed instead of small and rounded; notebook changing colour; readable numbers appearing on the monitor. | + +### Shot 3 — Escalation + +| Field | Specification | +|---|---| +| Shot number | 3 of 4 | +| Time / duration | 10.0–16.5 s (6.5 s) | +| Story purpose | The market answers: a brief dip freezes Bruno mid-celebration and spikes Boris's alarm. | +| Visible characters | Bruno (primary, frozen mid-celebration); Boris (secondary, reacting); Milo's mug and shoulder entering frame edge as a plant for Shot 4. | +| Composition & safe area | Two-shot: Bruno frame-right in the middle third, Boris frame-left slightly lower; the wall chart's dipping line visible in the upper third at the **same screen height** as Shot 1. Both faces inside the safe area. | +| Character action | Bruno's arms stop at their peak, one hoof still lifted; loose papers he knocked up are still falling. Boris grabs his glasses with one paw and the notebook with the other. | +| Facial expression | Bruno: smile collapsing into uncertainty, eyes flicking sideways to the chart. Boris: alarmed, mouth open, freeze-before-panic posture. | +| Beginning state | Celebration at maximum; chart line at local high. | +| Ending state | Bruno statue-still, papers mid-air; chart line visibly lower by `{{PULLBACK_PCT}}`; red accent rising. | +| Camera framing | Medium two-shot, eye level. | +| Dominant camera movement | **Controlled handheld reaction** (small, motivated settle — no shake for its own sake). | +| Lighting & market accent | Green accent fades; **red accent** `#EA3943` rises as practical alert light on the wall and rims, while warm key preserves fur and skin tones. Shape cue (falling line, papers) reinforces meaning so colour is not the only signal. | +| Environment & props | Wall BTC chart with a dipping line, falling loose papers, Bruno's workstation edge, glass wall to Block Street showing distant red accents, Milo's matte black mug at frame edge. | +| English dialogue | Bruno: "It's still green… right?" | +| zh-Hant subtitle | 「還是綠的……對吧?」 | +| Sound effects | Paper flutter, descending alert tone, a single low bass drop; Boris's short inhale. | +| Music direction | Cut the drive; leave a hollow low pulse and room tone. | +| Transition out | Cut on Bruno's held stillness → Shot 4 (silence carries across the cut). | +| Continuity requirements | Bruno's wardrobe unchanged from Shot 1; Boris's props unchanged from Shot 2; the resistance line stays at a consistent screen height; papers that leave Bruno's desk in this shot may appear settled in Shot 4. | +| Generation risks | Duplicate characters; extra limbs from the frozen pose; wardrobe change between Shots 1 and 3; full-frame red wash destroying fur colour; motion blur so heavy the faces stop reading; readable price text baked in. | + +### Shot 4 — Punchline + +| Field | Specification | +|---|---| +| Shot number | 4 of 4 | +| Time / duration | 16.5–21.0 s (4.5 s) | +| Story purpose | Milo lands the understated reveal — the confetti beats the candle close — and closes the emotional arc calmly. | +| Visible characters | Milo (primary, coffee corner); Bruno and Boris soft in background, still frozen. | +| Composition & safe area | Milo centred in the middle third at his coffee station, shortest of the trio so his eyeline sits lower in frame; Bruno and Boris out of focus behind at frame right and left. Milo's face and mug inside the safe area. | +| Character action | Milo lowers the matte black mug after one controlled sip, gives his phone screen a brief downward glance (delivery cue), slow blink, tail giving one unhurried flick. | +| Facial expression | Calm, faintly amused; emerald-green eyes half-lidded; no smirk larger than necessary. | +| Beginning state | Mug at lips, steam rising, warm amber practical light on his face; phone face-up on the counter, screen glow only. | +| Ending state | Mug lowered to chest height, gaze returning level to camera-adjacent, tail settled. | +| Camera framing | Reaction close-up (chest-up), eye level. | +| Dominant camera movement | **Short pan to Milo** — canonical and locked; a single short pan settling on him. *Regeneration fallback only:* if repeated attempts show identity drift during the pan, a locked frame may be substituted. The fallback is a recovery measure, not an equal production option. | +| Lighting & market accent | Warm amber `#F5B942` practical from the coffee corner as the dominant key; residual red accent only as a distant rim on the background pair; teal `#18A7A0` bounce on his turtleneck. | +| Environment & props | Coffee station, matte black mug, teal accent surface, phone face-up on the counter (screen glow only — no legible content in the plate), background curved desk with settled papers, glass wall beyond. | +| English dialogue | Milo: "Your confetti arrives before candle close." | +| zh-Hant subtitle | 「你的彩帶會比收盤先到。」 | +| Sound effects | Single ceramic set-down, quiet sip, one short delivery-notification chime from the phone, room tone. | +| Music direction | One clean resolving note on the line, then space. No sting stacked on top of the joke. | +| Transition out | Hold ~0.3 s of stillness, then cut to the post-production fact card (no dialogue over the card). | +| Continuity requirements | Dark teal turtleneck, slim black trousers, small silver Bitcoin pin on **left** chest, three forehead stripes, ringed tail, cream chin/chest patch and paw tips. Milo remains clearly shortest; background pair keep the poses they ended Shot 3 with. The phone is an ordinary supporting screen prop (Bible §2.5), not a new signature prop, and never becomes a character trait. | +| Generation risks | Milo rendered with glasses/jacket/hat; stripe count or eye colour drift; pin moving side or growing; tail duplicated or missing rings; background characters re-posing or changing wardrobe; mug changing to glossy or coloured; **legible text generated on the phone screen** (must stay an abstract glow — any readable notification is composited in post). | + +### Post-production card (not a generated shot) + +21.0–24.0 s. Full-screen graphic over a defocused, colour-graded still of Shot 4's final frame, +or a clean brand plate on `#0B1220`. All readable financial text lives **here**, never inside +AI-generated imagery. + +## 6. Dialogue table + +| # | Speaker | English line | zh-Hant subtitle | Intended delivery | Words | Est. spoken duration | +|---|---|---|---|---|---|---| +| 1 | Bruno | Breakout! We are so back! | 突破了!我們回來了! | Warm baritone, loud but not screaming; celebratory, on the move. | 5 | ~1.6 s | +| 2 | Boris | The candle hasn't closed yet. | 這根K棒還沒收。 | Low, controlled, clipped; a reluctant correction, not a lecture. | 5 | ~1.8 s | +| 3 | Bruno | It's still green… right? | 還是綠的……對吧? | Confidence draining mid-line; the question is genuine and quiet. | 4 | ~1.5 s | +| 4 | Milo | Your confetti arrives before candle close. | 你的彩帶會比收盤先到。 | Smooth, understated, slightly amused; no emphasis on the joke. | 6 | ~2.2 s | + +Total spoken ≈ 7.1 s across 24.0 s — the rest is action, reaction, and silence. Every line is +under eight English words. No line explains the market ("as you know…" is absent); the concept is +carried by Boris's single objection plus the fact card. Milo speaks last and least. + +**Why line 4 lands.** It ties the joke directly to the episode's market point: Bruno acted on the +initial signal, so the confetti will physically arrive **before** the candle that would confirm +the move has even closed. The line reveals the pre-order without stating it, and it needs no +readable on-screen text to work — the phone glance plus the delivery chime carry the cue. + +## 7. Character continuity sheet (this episode) + +Names are metadata only. Every prompt must carry the full locked identity below or an approved +reference image — never "Bruno Bull" alone. + +### Bruno Bull + +- **Immutable identity:** anthropomorphic bull; broad shoulders, strong upper body; warm + chestnut-brown fur; cream muzzle; symmetrical ivory horns with dark tips curving outward then + upward; thick dark eyebrows; amber-brown eyes; late 20s; **tallest of the trio**. +- **Locked wardrobe/props:** dark forest-green rolled-sleeve shirt; mustard-yellow tie, loosened; + black smartwatch on **left** wrist. +- **Episode emotion range:** elation → suspended uncertainty → sheepish stillness. He never + becomes stupid or humiliated; he is simply early. Likable throughout. +- **Episode action range:** fast entry, broad raised-arm gestures, weighty hoof impacts, + secondary motion in horns and tie, then a full freeze. +- **Screen position:** enters frame right (Shot 1); frame right in the Shot 3 two-shot; + background right and defocused in Shot 4. +- **Visual negatives:** no jacket, no hat, no glasses; no extra or asymmetric horns; no human + hands replacing hooves; no wardrobe change between shots; no watch on the right wrist; no fur + colour drift toward red or black. + +### Boris Bear + +- **Immutable identity:** anthropomorphic bear; heavy, rounded, compact power; deep + charcoal-brown fur; lighter gray-brown muzzle and inner ears; **small rounded ears**; + steel-blue eyes; early 30s; slightly shorter than Bruno, **much wider than Milo**. +- **Locked wardrobe/props:** burgundy knit vest over pale blue dress shirt; dark navy trousers; + rectangular black reading glasses; red risk notebook **and** red pen. +- **Episode emotion range:** wary → firm objection → alarmed overreaction. He is **correct** + about confirmation and still visibly overreacts; correctness and composure are separate. +- **Episode action range:** small guarded gestures, flat-paw stop, glasses adjust, notebook + clutch, freeze-before-panic, subtle ear reaction to alerts. +- **Screen position:** background left (Shot 1); centre-left (Shot 2); frame left (Shot 3); + background left and defocused (Shot 4). +- **Visual negatives:** glasses never disappear or change to round frames; no jacket over the + vest; ears never large or pointed; fur never pure black; no human hands; notebook never changes + colour; no wardrobe change between shots. + +### Milo Cat + +- **Immutable identity:** anthropomorphic orange tabby cat; slim, upright; triangular ears; + expressive ringed tail; burnt-orange tabby fur with darker stripes — **three forehead marks**, + cheek stripes, ringed tail; cream chin, chest patch and paw tips; emerald-green eyes; + mid-to-late 20s; **shortest of the trio**. +- **Locked wardrobe/props:** dark teal turtleneck; slim black trousers; small silver Bitcoin pin + on **left** chest; matte black coffee mug. +- **Episode emotion range:** unbothered observation → the faintest amusement. Never smug enough + to become unlikable; he withholds, he does not gloat. +- **Episode action range:** economical movement, one controlled sip, slow blink, single tail + flick, stillness while the room is chaotic. +- **Screen position:** absent from Shots 1–2 (mug enters frame edge in Shot 3); centre in Shot 4. +- **Visual negatives:** no glasses, no jacket, no hat; stripe count and placement never change; + eye colour never shifts to yellow/blue; pin never moves to the right chest or scales up; tail + never duplicated or unringed; mug never glossy or coloured; no human hands replacing paws. + +**Trio rules.** Height order on screen is always Bruno > Boris > Milo. No character is the +permanently intelligent one: Boris is right on the facts and wrong on proportion; Bruno is early, +not foolish; Milo is observant but contributes nothing until the last second. No legacy +characters (Fox, Hammy, Monkey, Walter) appear in any capacity. + +## 8. Environment continuity — The Burrow (locked for EP001) + +**Fixed layout (screen-direction map, camera facing the chart wall as the reference axis):** + +- **Wall-sized BTC chart** — upper third of the chart wall, frame **centre-left**; the horizontal + resistance line sits at a consistent screen height in every shot that shows it. +- **Central curved trading desk** — mid-frame, curving from frame left toward frame right, + foreground edge in the lower third. +- **Three workstations** — Boris **left** (monitor, red notebook, red pen), Bruno **right** + (cluttered, loose papers), Milo **centre-right toward the coffee corner** (tidiest). +- **Coffee station** — frame **right** wall, warm amber practical light; Milo's territory. +- **Glass wall overlooking Block Street** — frame **rear/right**, visible behind the desk; distant + city market accents only, never legible signage. +- **Doorway** — frame **right**, adjacent to the glass wall; Bruno's entrance in Shot 1. + +**Screen direction.** Bruno enters right→left. Boris occupies left. Milo occupies right/centre. +Nobody crosses to the opposite side within this episode, so cuts stay spatially legible. + +**Approved market-light usage.** Default is cool office ambience with a warm facial key. Bullish +moments add **green** `#16C784` as a secondary accent (Shot 1). Bearish moments add **red** +`#EA3943` as alert accents (Shot 3). Accents never become a full-frame neon wash and never +override fur or skin tone. Milo's corner keeps its warm amber practical regardless of market +state. Because red/green must not be the only carrier of meaning, each market change is also +expressed by chart-line shape, prop behaviour (falling papers), and character reaction. + +**Anti-drift rules.** Desk curvature, chart position, workstation ownership, coffee-station side, +and glass-wall placement are fixed for all four shots. Props may move only as scripted (papers +launched in Shot 3 may be settled in Shot 4). No new furniture, plants, posters, screens, or +signage may appear between shots. No provider logos, watermarks, or real-company branding +anywhere. Background monitors show abstract, unreadable chart shapes only. + +## 9. Text and graphics plan + +- **All final readable financial text is added in post-production.** Nothing legible and factual + is trusted to generated imagery. +- **Chart placeholders in generated footage:** abstract rising line crossing a horizontal level + (Shot 1), the same line visibly lower (Shot 3). No digits, tickers, axis labels, or exchange + names in the plate. Real values are composited later if needed. +- **Subtitle safe area:** zh-Hant subtitles sit in the lower ~18% of the 9:16 frame, above + platform UI, horizontally centred, max two lines, high-contrast with a subtle scrim. Never + overlapping a character's face or the chart's resistance line. Cue 4 + (「你的彩帶會比收盤先到。」) is the longest at ~2.2 s and stays on a single line; it must clear + before the fact card appears. +- **Delivery-notification overlay (optional, post-production only):** if the confetti order needs + to be spelled out visually, a small notification graphic may be composited over Milo's phone + during Shot 4. It is **optional** — the glance plus the chime plus the line already carry the + cue. The generated plate must contain only an abstract screen glow; no legible notification may + come out of the image/video provider. A visible delivery box is **not** required anywhere. +- **Fact card wording (21.0–24.0 s):** + > Moving above `{{RESISTANCE_LEVEL}}` is the initial breakout signal — not a confirmed breakout. + > Some traders wait for the `{{TIMEFRAME}}` candle to close above it and follow through. + > Confirmation criteria don't guarantee future performance. + > Source: `{{DATA_SOURCE}}` · `{{BREAKOUT_TIMESTAMP_UTC}}` +- **Disclaimer treatment:** "For education and entertainment, not financial advice." — one line, + smaller weight, bottom of the fact card, present for the card's full duration. +- **CTA:** optional and only on the fact card, never over Milo's line. If used, a single quiet + question such as "Do you wait for the close?" It must not compete with the punchline; if in + doubt, omit it. + +## 10. Audio direction + +**Voice direction.** + +- *Bruno* — energetic warm baritone; celebratory, projected but **never screaming**; slight rasp + of excitement; lines land fast and land early. +- *Boris* — low, controlled, a touch clipped; the stress shows as tightness, and only the tail of + his line may crack; no shouting. +- *Milo* — smooth, understated, slightly amused; low volume, unhurried; the punchline is placed, + not sold. + +**Music arc.** Rising synth stab on the alert (Shot 1) → pull back to a single sustained tense +note under Boris (Shot 2) → drop to a hollow low pulse and near-silence on the freeze (Shot 3) → +one clean resolving note on Milo's line, then space (Shot 4) → soft brand bed under the fact card. + +**Required SFX.** Door swing; bright alert chime; weighted hoof impacts; notebook flap; chair +creak; descending alert tone; low bass drop; paper flutter; ceramic mug set-down; quiet sip; +one soft distant delivery buzzer; continuous The Burrow room tone. + +**Mixing priority.** 1) dialogue intelligibility, 2) the punchline's surrounding silence, +3) story-critical SFX (alert, papers, mug), 4) music, 5) ambience. Music ducks under every line. +Nothing is layered on top of Milo's last four words except room tone. + +**Subtitle timing.** Subtitles appear on the first phoneme and clear ~0.2–0.3 s after the line +ends; minimum 1.0 s on screen; never bridging a hard cut; the punchline subtitle clears before +the fact card so the two never coexist. + +## 11. Production asset checklist + +**Character reference images (per character — Bruno, Boris, Milo):** front neutral portrait; left +and right three-quarter views; full-body front and side; six-expression sheet (neutral, elation, +alarm, freeze/uncertainty, calm amusement, slow blink); colour palette swatch; signature prop +plate (tie+watch / notebook+pen+glasses / mug+pin); written immutable description; approved +negative prompt. + +**Environment references:** The Burrow master wide; chart-wall elevation with the resistance line +height marked; curved desk plan showing all three workstations; coffee-station corner; glass wall +with Block Street beyond; doorway; lighting variants (default cool, green accent, red accent). + +**Prop references:** wall BTC chart plate (abstract, textless); Bruno's loose papers; Boris's red +notebook and red pen; Boris's monitor with abstract chart; Milo's matte black mug; ordinary phone +prop for Shot 4 (screen glow only, textless — a supporting screen per Bible §2.5, not a signature +prop). No delivery box is required; if a confetti cue is ever shown it stays background-only. + +**Market-data inputs:** every placeholder in §3, plus source URL and a factual note confirming +the observed move. + +**Voice assets:** four TTS or recorded lines (one per dialogue row) with the §10 direction, plus +optional non-verbal breaths for Bruno's freeze and Boris's inhale. + +**Post-production graphics:** zh-Hant subtitle set (4 cues); fact card with placeholder +substitution; disclaimer line; optional CTA; optional composited chart values; optional +delivery-notification overlay for Milo's phone in Shot 4; brand plate. + +**Expected Image Provider outputs:** first/last/key frame stills per shot at 9:16, character- and +environment-consistent, textless. + +**Expected Video Provider outputs:** four 9:16 clips matching the specified durations (3.0 / 7.0 / +6.5 / 4.5 s), one dominant camera movement each, no baked text. + +**Expected TTS + composition (FFmpeg) outputs:** four voice tracks; SFX and music stems; a 24.0 s +9:16 master with burned or sidecar zh-Hant subtitles and the post-production fact card appended. + +## 12. Quality acceptance checklist + +### Automatically verifiable + +> **Scope note.** These are **pre-render / data-lock acceptance conditions**, evaluated against a +> production-bound episode package immediately before rendering — not statements about the current +> state of this design document. This document deliberately still contains unresolved +> `{{PLACEHOLDER}}` tokens (see §3); that is correct at the design stage and is not a failure. + +- Total runtime is 24.0 s ±0.5 s and inside 15–30 s. +- Output aspect ratio is exactly 9:16. +- Exactly four generated shots exist, with durations 3.0 / 7.0 / 6.5 / 4.5 s. +- Exactly three characters are credited, matching the canonical trio; no legacy character key + (fox, hammy, monkey, walter) appears anywhere in the episode data. +- Four dialogue lines exist; each has both an English line and a non-empty zh-Hant subtitle. +- Every English line is fewer than eight words. +- Milo speaks the final line. +- At data lock (pre-render), no `{{PLACEHOLDER}}` remains unresolved in any production-bound + field. Unresolved placeholders in this design document are expected and do not fail this check. +- Required source metadata is present (`{{DATA_SOURCE}}`, `{{BREAKOUT_TIMESTAMP_UTC}}`, + `{{TIMEFRAME}}`, `{{CANDLE_CLOSE_TIME_UTC}}`). +- Prohibited-phrase scan passes: no "guaranteed", "risk-free", "buy now", "sell now", + "price target", "financial advice" in spoken lines. +- The disclaimer string is present on the fact card. +- Each shot declares exactly one camera movement. +- Subtitles sit inside the defined lower safe area and never overlap a hard cut. +- No shot contains more than one dominant camera action field. + +### Human creative review + +- Each character is immediately recognizable and matches the Bible: fur, markings, eye colour, + wardrobe, and signature props are correct in every shot. +- Height order reads Bruno > Boris > Milo on screen. +- The humour comes from personality collision, not from noise, memes, or cruelty. +- Bruno stays likable rather than incompetent; Boris is right about confirmation yet visibly + overreacts; Milo's punchline is understated and lands cleanly with silence after it. +- A viewer with no trading background understands that moving above resistance is the initial + breakout signal, not a confirmed breakout, and that confirmation criteria are not a guarantee. +- Milo's punchline reads without any on-screen text: the phone glance, the delivery chime, and the + line alone make the pre-ordered confetti understandable. +- The zh-Hant subtitles carry the same meaning and comic timing as the English, and read + naturally rather than literally. +- No market claim exceeds what the supplied source data supports. +- Continuity holds: wardrobe, props, chart-line height, screen direction, and background layout + are stable across all four shots; Shot 4's background pair match their Shot 3 poses. +- No anatomy or identity defect distracts (extra limbs/horns/ears/tails, duplicate characters, + human hands). +- Subtitles and the fact card are legible on a phone at arm's length. +- No provider logo, watermark, or unintended real-company branding is visible. +- No readable AI-generated financial text survives in the final footage. +- Nothing imitates a recognizable entertainment franchise's characters or signature style. diff --git a/docs/crypto-animal-studio/import-mapper-v1.md b/docs/crypto-animal-studio/import-mapper-v1.md index 9f53aad6..920d17f7 100644 --- a/docs/crypto-animal-studio/import-mapper-v1.md +++ b/docs/crypto-animal-studio/import-mapper-v1.md @@ -143,3 +143,75 @@ bear: Back from what. - **One transaction**, one commit (owned by the request session `get_db`); any failure → full rollback, no partial Chapter/Shots. - **Dry-run** performs validation + mapping + reuse lookup + warnings, then rolls back (writes nothing). - **Idempotency** via `cas_import_ledger` (durable): same `(project, key)` + same `payload_hash` → replay existing chapter; same key + different payload → 409; same `(project, episode)` under another key → 409. Ledger design: see **ADR-013**. The ledger row is written on the **same session/transaction** as all imported rows; a ledger insert failure rolls back the entire episode. + +--- + +## v1.1 additions (Step 5) + +| EpisodePackage v1.1 field | Jellyfish target | Note | +|---|---|---| +| `output.*` (aspect_ratio, fps, safe_area) | *(not persisted)* | Render-time spec; consumed by the CAS production pipeline, not by Jellyfish entities. | +| `localization.spoken_language` | *(not persisted)* | Dialogue language; `ShotDialogLine.text` holds the spoken (English) line. | +| `localization.subtitle_tracks[].cues[]` | *(not persisted — by decision)* | Jellyfish has no subtitle/language column. Cues stay in the package, in `Chapter.raw_text` traceability and in the CAS `ArtifactType.subtitle` artifact. Covered by the canonical payload hash. | +| `fact_card`, `market_data`, `references`, `post_production` | *(not persisted)* | Post-production and provenance metadata; no Jellyfish entity models them. | +| `shots[].beginning_state` / `ending_state` / `generation_risks[]` / `regeneration_fallback` / `overlay_ids[]` | *(not persisted)* | Generation guidance consumed by the prompt builder. | + +**QA gate.** `import_episode()` runs `validate_episode_package(stage=pre_render_data_lock)` before +any entity is constructed. Failure raises `CasValidationError` → HTTP 422 with **zero** rows written. + +**Async path.** `POST /import/async` registers a `cas_import_episode_package` task +(relation type `cas_episode_import`, entity key = SHA-256 of `project_id:episode_id`, 64 chars to fit +`relation_entity_id VARCHAR(64)`). No migration is required. + +--- + +## Step 5.1 — subtitle artifact, worker, client contract + +### zh-Hant WebVTT artifact + +| v1.1 field | WebVTT / Jellyfish target | +|---|---| +| `subtitle_tracks[].language_tag` | `Language:` header line + `FileItem.tags` + `file_usages.source_ref` | +| `cues[].cue_id` | WebVTT cue identifier line | +| cue order | block order (declared order preserved, never re-sorted) | +| `cues[].start_ms` / `end_ms` | `HH:MM:SS.mmm --> HH:MM:SS.mmm` | +| `cues[].text` | cue payload, byte-for-byte | +| `cues[].shot_id` | `NOTE shot=` before the cue | +| `cues[].speaker_character_key` | `NOTE speaker=` before the cue | + +Storage key is deterministic: `cas/subtitles/{project_id}/{episode_id}/{language_tag}.vtt`. +Association uses the existing file-linking mechanism — `FileItem` (`type=subtitle`) plus +`FileUsage(project_id, chapter_id, usage_kind=subtitle, source_ref="cas:{episode_id}:{lang}")`, +whose `UNIQUE(file_id, usage_kind, source_ref)` gives per-slot idempotency. + +**Limitation.** Subtitle cues are **preserved and downloadable** through the existing files +endpoint, but they are **not editable as native Jellyfish entities** — there is no subtitle table +or column, by the accepted no-migration decision. Editing requires re-importing a corrected +Episode Package. + +### Consistency (no atomicity claimed) + +Object storage does not participate in the DB transaction. The strategy is deterministic key + +compensating cleanup: object written first, DB rows second; on failure the newly created objects +are deleted (reused objects from a previous successful import are never deleted). A hard process +kill can leave one orphan at a deterministic key, which the next successful import overwrites and +which no DB row references. + +### Worker + +`task_kind="cas_import_episode_package"` is registered in `app/services/worker/task_registry.py` +with `AbstractAsyncDelegatingExecutor` (timeout 300 s) and enqueued through the existing +`enqueue_task_execution` → Celery `task.execute` path. No separate queue or task system. + +### Client contract + +`front/openapi.json` and `front/src/services/openapi.ts` are checked-in generated artifacts and were +**not** regenerated (pnpm + `openapi-typescript-codegen` are unavailable offline). Regenerate with: + +```bash +cd front && pnpm run openapi:update # requires the backend running on :8000 +``` + +New/changed contract surface to expect: `POST /api/v1/crypto-animal-studio/import/async` +(request `ImportEpisodeRequest`, response `ApiResponse`), the +`subtitle_artifacts[]` field on `ImportResult`, and the widened `FileTypeEnum` (`image|video|subtitle`). diff --git a/docs/implementation-log.md b/docs/implementation-log.md index d1e43447..6703dc56 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -4,6 +4,56 @@ Chronological log of CAS↔Jellyfish integration sprints. Newest first. --- +## Sprint 4 — CAS Production MVP Skeleton + +Date: 2026-07-26 + +### Summary +Added a deterministic, traceable, **mock-only** end-to-end production pipeline that consumes a +valid EpisodePackage and produces artifacts + a manifest. No real providers, no FFmpeg, no LLM, +no Celery/Redis. EpisodePackage v1 and the importer contract are unchanged. + +### Architecture decisions (see ADR-014) +- Production state (`cas_production_jobs/shots/artifacts`) is **separate** from creative models; + `source_shot_id` is a weak reference, never a replacement Shot. +- **Artifact First**: every stage output is a DB row + real file with SHA-256 checksum. +- Providers are **adapters** (`ImageProvider`/`VideoProvider`/`VoiceProvider`/`Composer` → + `GeneratedArtifact`); orchestration contains no provider specifics; providers never build paths. +- **ArtifactManager owns the filesystem** (`storage/cas/productions/{project}/{episode}/{job}/...`, + relative POSIX paths in DB, `CAS_STORAGE_ROOT` override, segment sanitization). +- Retry restarts at the failed stage, reruns it and later stages, and **reuses** earlier artifacts + when row+file+checksum are valid. The package hash is re-validated even when resuming. + +### Files created +- `backend/app/crypto_animal_studio/production/{__init__,enums,models,prompt_builder,artifact_manager,orchestrator,cli}.py` +- `backend/app/crypto_animal_studio/production/providers/{__init__,base,mock}.py` +- `backend/app/crypto_animal_studio/schemas/production.py` +- `backend/app/crypto_animal_studio/api/production.py` +- `backend/sql/010-add-cas-production-tables.sql` +- `backend/tests/test_cas_production.py`, `backend/tests/test_cas_production_api_cli.py` +- `samples/cas/demo_episode.json` +- `docs/adr/ADR-014-cas-production-pipeline.md`, `docs/cas-production-mvp.md` + +### Files modified +- `backend/app/crypto_animal_studio/api/__init__.py` — register production router. +- `backend/app/core/db.py` — register production models in `init_db()`. +- `docs/implementation-log.md` — this entry. + +### Tests & results +CAS suite: **68 passed, 0 skipped** (49 existing + 19 new: 14 production + 5 API/CLI). + +### Known limitations +- Synchronous execution; local filesystem storage; mock artifacts are `.txt` placeholders. +- `music` / `log` artifact types defined but not produced. +- Deviations from the sprint text (repo conventions win): module lives at + `app/crypto_animal_studio/production` (not `app.cas.*`) to avoid a second CAS module; API is + mounted at `/api/v1/crypto-animal-studio/production/...` (not `/api/cas/...`). +- **Sprint 3.5 governance files (CONTRIBUTING/CHANGELOG/ROADMAP/docs/adr/README/.github templates) + are absent from the working tree** — they were never committed and were lost; they need to be + recreated or restored separately. + +--- + ## Sprint 3 — EpisodePackage Importer v1 Date: 2026-07-24 @@ -213,3 +263,101 @@ or frontend work was done (per sprint scope). - `rm -f docs/implementation-log.md` 3. No DB/migration/ORM/enum/provider changes were made, so no data or schema rollback is required. 4. Equivalent via VCS: `git restore backend/app/api/v1/__init__.py` and `git clean -fd backend/app/crypto_animal_studio backend/tests/test_cas_*.py docs/crypto-animal-studio docs/implementation-log.md`. + +--- + +## Step 5 — EP001 Production Vertical Slice + +**Objective:** prove one canonical Crypto Animal Studio episode moves from a v1.1 Episode Package +into Jellyfish's editable production entities. + +### Decisions taken before implementation +1. **Runtime — EP001 stays at 24.0 s.** The Step 5 brief proposed 45–60 s, which conflicts with the + approved EP001 design doc (24.0 s) and ADR-016 §7 (15–30 s canonical publish range, enforced by + `PUBLISH_MIN/MAX_TOTAL_MS`). Approved canon wins; no ADR was changed. +2. **World naming — repository canon.** The brief said "Crypto Animal Street"; Bible v1 §2 defines + the world as **Block Street** and EP001 §8 locks the location as **The Burrow**. Repository canon + used; no replacement invented. +3. **Subtitles — no migration.** Jellyfish has *no* subtitle/caption/language column anywhere + (`grep -rn "subtitle|caption|translation|lang" app/models/*.py` → 0 matches); `shot_dialog_lines` + carries a single `text`. v1.1 models subtitles as timed `Localization.subtitle_tracks[].cues[]`. + Rather than add a table, the approved decision is: English dialogue maps to + `ShotDialogLine.text`; zh-Hant cues stay in the Episode Package, in `Chapter.raw_text` + traceability, and in the existing CAS production artifact (`ArtifactType.subtitle`). Subtitles are + covered by the canonical payload hash, so they cannot be silently dropped. + **Consequence:** subtitles are not editable as Jellyfish entities in this slice. + +### Changes +- **QA gate wired into the importer.** `import_episode()` now runs + `validate_episode_package(stage=pre_render_data_lock)` **before constructing any entity** and + raises the new typed `CasValidationError`; validation failure therefore writes zero rows. Both + existing samples pass this stage, so v1 behaviour is unchanged. API translates it to 422. +- **Async task center integration.** New `application/import_tasks.py` adds task kind + `cas_import_episode_package` and relation type `cas_episode_import`, mirroring + `services/script_processing_tasks.py`. **No migration**: `generation_tasks.task_kind` and + `generation_task_links.relation_type` are free-form string columns. +- **New route** `POST /api/v1/crypto-animal-studio/import/async` reusing the *same* + `ImportEpisodeRequest`, so contract validation is identical to the sync route. +- **EP001 production package** at `samples/cas/ep001_btc_breakout.json` (schema 1.1, 24.0 s, 9:16, + 4 shots, en dialogue + zh-Hant track). Passes all five validation stages. + +### Verification (sandbox Python 3.10.12) +- pytest **480 passed** (464 baseline + 16 new), run in chunks, with + `-W error::pytest.PytestUnhandledThreadExceptionWarning` — no such warning. +- `compileall -q app` → exit 0. +- `pylint app` → 10.00/10 across all chunks, except 3 known 3.10-only `E0611` artifacts + (`datetime.UTC`, `typing.Self` are 3.11+ builtins). + +### Remaining gaps before frontend / ComfyUI +- Subtitles not persisted as editable entities (decision 3 above); revisit if the editor must edit them. +- No frontend client regeneration (`pnpm run openapi:update`) for the new async route. +- `run_cas_import_task` is driven directly; no Celery/worker spawn wired yet. +- `video_prompt` / `negative_prompt` still have no `ShotDetail` field and surface as import warnings. + +### Step 5.1 — formal acceptance (supported environment) + +Verified on Windows in the repository-supported environment: + +| Command | Result | Exit | +|---|---|---| +| `uv run python --version` | Python 3.12.13 | 0 | +| `uv run pytest -W error::pytest.PytestUnhandledThreadExceptionWarning` | 495 passed in 27.92s, no `PytestUnhandledThreadExceptionWarning` | 0 | +| `uv run pylint app` | 10.00/10 | 0 | +| `uv run python -m compileall -q app` | — | 0 | + +**Step 5.1 is formally accepted.** The subtitle artifact pipeline (deterministic zh-Hant WebVTT via +`FileItem` + `FileUsage`), the real task-worker registration (`cas_import_episode_package` through +the existing Celery `task.execute` registry), the EP001 import vertical slice and the Episode +Package v1.1 contract are all frozen from this point and must not be rebuilt or redesigned. + +--- + +## Step 6 — EP001 Production Workspace: formal acceptance + +Verified in the repository-supported environments (Windows; backend Python 3.12.13, +frontend pnpm 9.15.9 / Node 22). + +| Command | Result | Exit | +|---|---|---| +| `pnpm exec vitest run` (frontend suite) | all tests passed | 0 | +| `pnpm run typecheck` | — | 0 | +| `pnpm run build` | — | 0 | +| `pnpm exec eslint` (Step 6 scope) | clean, 0 problems | 0 | +| `pnpm run lint` (repository-wide) | 198 findings, **all pre-existing** | 1 | +| `uv run pytest -W error::pytest.PytestUnhandledThreadExceptionWarning` | 499 passed | 0 | +| `uv run pylint app` | — | 0 | +| `uv run python -m compileall -q app` | — | 0 | + +**Repository-wide frontend lint exits 1 on the pre-existing baseline only.** Step 6 introduced +two findings and both were fixed (`no-irregular-whitespace` in `webvtt.ts`, a literal U+FEFF in +the BOM regex now written as the escape `\uFEFF`; and `consistent-type-imports` in the workspace test). The +count moved 200 → 198, matching exactly. No Step 6 file appears in the remaining output. + +Of the 198 pre-existing findings, two deserve separate attention as genuine defects rather than +style debt: `react-hooks/rules-of-hooks` errors at +`src/pages/aiStudio/shots/ChapterShotEditPage.tsx:1285` and `:1299`, where hooks are called after +an early return. Not touched here — outside Step 6 scope. + +**Step 6 is accepted.** The EP001 production workspace, the `chapter_id` / `usage_kind` file +filters, the Vitest + React Testing Library infrastructure and the regenerated OpenAPI client are +frozen alongside the Step 5 / 5.1 importer, subtitle artifact pipeline and worker registration. diff --git a/front/openapi.json b/front/openapi.json index 4f29e89a..188db686 100644 --- a/front/openapi.json +++ b/front/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Jellyfish API","version":"0.1.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"V1 Health","operationId":"v1_health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}}}}},"/api/v1/film/tasks/video/preview-prompt":{"post":{"tags":["film"],"summary":"视频提示词预览","description":"预览视频生成的提示词与自动关联参考图。","operationId":"preview_video_generation_prompt_api_v1_film_tasks_video_preview_prompt_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VideoPromptPreviewResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/video":{"post":{"tags":["film"],"summary":"视频生成(任务版)","description":"创建视频生成任务并后台执行,结果通过 /tasks/{task_id}/result 获取。","operationId":"create_video_generation_task_api_v1_film_tasks_video_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/shot-frame-prompts":{"post":{"tags":["film"],"summary":"镜头分镜帧提示词生成(任务版)","operationId":"create_shot_frame_prompt_task_api_v1_film_tasks_shot_frame_prompts_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFramePromptRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks":{"get":{"tags":["film"],"summary":"全局任务列表(任务中心)","operationId":"list_tasks_api_v1_film_tasks_get","parameters":[{"name":"statuses","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/TaskStatus"}},{"type":"null"}],"description":"按任务状态过滤,可多选","title":"Statuses"},"description":"按任务状态过滤,可多选"},{"name":"task_kind","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 task_kind 过滤","title":"Task Kind"},"description":"按 task_kind 过滤"},{"name":"relation_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_type 过滤","title":"Relation Type"},"description":"按 relation_type 过滤"},{"name":"relation_entity_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_entity_id 过滤","title":"Relation Entity Id"},"description":"按 relation_entity_id 过滤"},{"name":"recent_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"description":"默认返回最近结束任务的时间窗口(秒)","default":300,"title":"Recent Seconds"},"description":"默认返回最近结束任务的时间窗口(秒)"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":20,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_TaskListItemRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/status":{"get":{"tags":["film"],"summary":"查询任务状态/进度(轮询)","operationId":"get_task_status_api_v1_film_tasks__task_id__status_get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskStatusRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/result":{"get":{"tags":["film"],"summary":"获取任务结果","operationId":"get_task_result_api_v1_film_tasks__task_id__result_get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/cancel":{"post":{"tags":["film"],"summary":"请求取消任务","operationId":"cancel_task_api_v1_film_tasks__task_id__cancel_post","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCancelRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCancelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/task-links/adopt":{"patch":{"tags":["film"],"summary":"更新任务关联的采用状态(仅可正向变更)","description":"将指定任务链接的状态设为 accepted;已采用不可改为未采用。","operationId":"adopt_task_link_api_v1_film_task_links_adopt_patch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskLinkAdoptRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskLinkAdoptRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/task-links":{"get":{"tags":["film"],"summary":"生成任务关联列表(分页,支持多条件过滤)","operationId":"list_task_links_api_v1_film_task_links_get","parameters":[{"name":"resource_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 resource_type 过滤","title":"Resource Type"},"description":"按 resource_type 过滤"},{"name":"relation_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_type 过滤","title":"Relation Type"},"description":"按 relation_type 过滤"},{"name":"relation_entity_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_entity_id 过滤","title":"Relation Entity Id"},"description":"按 relation_entity_id 过滤"},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按关联状态过滤(accepted/todo/rejected)","title":"Status"},"description":"按关联状态过滤(accepted/todo/rejected)"},{"name":"task_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 task_id 过滤","title":"Task Id"},"description":"按 task_id 过滤"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段:updated_at/created_at/id/status","title":"Order"},"description":"排序字段:updated_at/created_at/id/status"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序;默认 true","default":true,"title":"Is Desc"},"description":"是否倒序;默认 true"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":10,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_GenerationTaskLinkRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["film"],"summary":"创建生成任务关联","operationId":"create_task_link_api_v1_film_task_links_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationTaskLinkCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_GenerationTaskLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/task-links/{link_id}":{"get":{"tags":["film"],"summary":"获取生成任务关联详情","operationId":"get_task_link_api_v1_film_task_links__link_id__get","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_GenerationTaskLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["film"],"summary":"更新生成任务关联(不支持直接修改 is_adopted)","operationId":"update_task_link_api_v1_film_task_links__link_id__patch","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationTaskLinkUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_GenerationTaskLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["film"],"summary":"删除生成任务关联","operationId":"delete_task_link_api_v1_film_task_links__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/providers":{"get":{"tags":["llm"],"summary":"列出模型供应商(分页)","operationId":"list_providers_api_v1_llm_providers_get","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段:name, created_at, updated_at","title":"Order"},"description":"排序字段:name, created_at, updated_at"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":10,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ProviderRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["llm"],"summary":"创建模型供应商","operationId":"create_provider_api_v1_llm_providers_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProviderRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/providers/supported":{"get":{"tags":["llm"],"summary":"列出系统支持的供应商能力","operationId":"list_supported_providers_api_v1_llm_providers_supported_get","parameters":[{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/ModelCategoryKey"},{"type":"null"}],"description":"按模型类别过滤:text/image/video","title":"Category"},"description":"按模型类别过滤:text/image/video"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ProviderSupportedRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/image-generation-options":{"get":{"tags":["llm"],"summary":"获取当前默认图片模型的关键帧规格选项","operationId":"get_image_generation_options_api_v1_llm_image_generation_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ImageGenerationOptionsRead_"}}}}}}},"/api/v1/llm/video-generation-options":{"get":{"tags":["llm"],"summary":"获取当前默认视频模型的动态比例选项","operationId":"get_video_generation_options_api_v1_llm_video_generation_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VideoGenerationOptionsRead_"}}}}}}},"/api/v1/llm/providers/{provider_id}":{"get":{"tags":["llm"],"summary":"获取单个模型供应商","operationId":"get_provider_api_v1_llm_providers__provider_id__get","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProviderRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["llm"],"summary":"更新模型供应商","operationId":"update_provider_api_v1_llm_providers__provider_id__patch","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProviderRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["llm"],"summary":"删除模型供应商","operationId":"delete_provider_api_v1_llm_providers__provider_id__delete","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/models":{"get":{"tags":["llm"],"summary":"列出模型(分页)","operationId":"list_models_api_v1_llm_models_get","parameters":[{"name":"provider_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按供应商过滤","title":"Provider Id"},"description":"按供应商过滤"},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/ModelCategoryKey"},{"type":"null"}],"description":"按模型类别过滤","title":"Category"},"description":"按模型类别过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段:name, category, created_at, updated_at","title":"Order"},"description":"排序字段:name, category, created_at, updated_at"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":10,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ModelRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["llm"],"summary":"创建模型","operationId":"create_model_api_v1_llm_models_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/models/{model_id}":{"get":{"tags":["llm"],"summary":"获取单个模型","operationId":"get_model_api_v1_llm_models__model_id__get","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["llm"],"summary":"更新模型","operationId":"update_model_api_v1_llm_models__model_id__patch","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["llm"],"summary":"删除模型","operationId":"delete_model_api_v1_llm_models__model_id__delete","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/model-settings":{"get":{"tags":["llm"],"summary":"获取模型全局设置(单例)","operationId":"get_model_settings_api_v1_llm_model_settings_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelSettingsRead_"}}}}}},"put":{"tags":["llm"],"summary":"更新模型全局设置(单例)","operationId":"update_model_settings_api_v1_llm_model_settings_put","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelSettingsUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelSettingsRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/projects/style-options":{"get":{"tags":["studio/projects"],"summary":"获取项目风格候选项","operationId":"get_project_style_options_api_v1_studio_projects_style_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectStyleOptionsRead_"}}}}}}},"/api/v1/studio/projects":{"get":{"tags":["studio/projects"],"summary":"项目列表(分页)","operationId":"list_projects_api_v1_studio_projects_get","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段","title":"Order"},"description":"排序字段"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ProjectRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/projects"],"summary":"创建项目","operationId":"create_project_api_v1_studio_projects_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/projects/{project_id}":{"get":{"tags":["studio/projects"],"summary":"获取项目","operationId":"get_project_api_v1_studio_projects__project_id__get","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/projects"],"summary":"更新项目","operationId":"update_project_api_v1_studio_projects__project_id__patch","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/projects"],"summary":"删除项目","operationId":"delete_project_api_v1_studio_projects__project_id__delete","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/chapters":{"get":{"tags":["studio/chapters"],"summary":"章节列表(分页)","operationId":"list_chapters_api_v1_studio_chapters_get","parameters":[{"name":"project_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按项目过滤","title":"Project Id"},"description":"按项目过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 title/summary","title":"Q"},"description":"关键字,过滤 title/summary"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段","title":"Order"},"description":"排序字段"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ChapterRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/chapters"],"summary":"创建章节","operationId":"create_chapter_api_v1_studio_chapters_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChapterCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ChapterRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/chapters/{chapter_id}":{"get":{"tags":["studio/chapters"],"summary":"获取章节","operationId":"get_chapter_api_v1_studio_chapters__chapter_id__get","parameters":[{"name":"chapter_id","in":"path","required":true,"schema":{"type":"string","title":"Chapter Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ChapterRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/chapters"],"summary":"更新章节","operationId":"update_chapter_api_v1_studio_chapters__chapter_id__patch","parameters":[{"name":"chapter_id","in":"path","required":true,"schema":{"type":"string","title":"Chapter Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChapterUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ChapterRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/chapters"],"summary":"删除章节","operationId":"delete_chapter_api_v1_studio_chapters__chapter_id__delete","parameters":[{"name":"chapter_id","in":"path","required":true,"schema":{"type":"string","title":"Chapter Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots":{"get":{"tags":["studio/shots"],"summary":"镜头列表(分页)","operationId":"list_shots_api_v1_studio_shots_get","parameters":[{"name":"chapter_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按章节过滤","title":"Chapter Id"},"description":"按章节过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 title/script_excerpt","title":"Q"},"description":"关键字,过滤 title/script_excerpt"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shots"],"summary":"创建镜头","operationId":"create_shot_api_v1_studio_shots_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/runtime-summary":{"get":{"tags":["studio/shots"],"summary":"按章节获取镜头运行时任务态摘要","operationId":"list_shot_runtime_summary_api_v1_studio_shots_runtime_summary_get","parameters":[{"name":"chapter_id","in":"query","required":true,"schema":{"type":"string","description":"章节 ID","title":"Chapter Id"},"description":"章节 ID"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotRuntimeSummaryRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/extraction-draft":{"get":{"tags":["studio/shots"],"summary":"分镜详情:按镜头关联拼装 StudioScriptExtractionDraft","operationId":"get_shot_extraction_draft_api_v1_studio_shots__shot_id__extraction_draft_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_StudioScriptExtractionDraft_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/extracted-candidates":{"get":{"tags":["studio/shots"],"summary":"获取镜头提取候选项","operationId":"get_shot_extracted_candidates_api_v1_studio_shots__shot_id__extracted_candidates_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotExtractedCandidateRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/extracted-dialogue-candidates":{"get":{"tags":["studio/shots"],"summary":"获取镜头提取对白候选项","operationId":"get_shot_extracted_dialogue_candidates_api_v1_studio_shots__shot_id__extracted_dialogue_candidates_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotExtractedDialogueCandidateRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/assets-overview":{"get":{"tags":["studio/shots"],"summary":"获取镜头资产总览(已关联资产 + 提取候选)","operationId":"get_shot_assets_overview_api_api_v1_studio_shots__shot_id__assets_overview_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotAssetsOverviewRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/preparation-state":{"get":{"tags":["studio/shots"],"summary":"获取镜头准备页聚合状态","operationId":"get_shot_preparation_state_api_api_v1_studio_shots__shot_id__preparation_state_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationStateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/preparation-link":{"post":{"tags":["studio/shots"],"summary":"准备页关联现有实体并返回最新聚合状态","operationId":"link_existing_asset_for_preparation_api_api_v1_studio_shots__shot_id__preparation_link_post","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotPreparationLinkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/video-prompt-preview":{"get":{"tags":["studio/shots"],"summary":"预览镜头视频提示词","operationId":"preview_shot_video_prompt_api_v1_studio_shots__shot_id__video_prompt_preview_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}},{"name":"template_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"指定视频提示词模板 ID;不传则使用默认模板","title":"Template Id"},"description":"指定视频提示词模板 ID;不传则使用默认模板"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotVideoPromptPreviewRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/video-readiness":{"get":{"tags":["studio/shots"],"summary":"获取镜头视频生成准备度","operationId":"get_shot_video_readiness_api_api_v1_studio_shots__shot_id__video_readiness_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}},{"name":"reference_mode","in":"query","required":false,"schema":{"type":"string","description":"参考模式:first/last/key/first_last/first_last_key/text_only","default":"text_only","title":"Reference Mode"},"description":"参考模式:first/last/key/first_last/first_last_key/text_only"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotVideoReadinessRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/skip-extraction":{"patch":{"tags":["studio/shots"],"summary":"设置是否跳过镜头信息提取","operationId":"update_shot_skip_extraction_api_v1_studio_shots__shot_id__skip_extraction_patch","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotSkipExtractionUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-candidates/{candidate_id}/link":{"patch":{"tags":["studio/shots"],"summary":"确认并关联镜头提取候选项","operationId":"link_extracted_candidate_api_v1_studio_shots_extracted_candidates__candidate_id__link_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotExtractedCandidateLinkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-candidates/{candidate_id}/ignore":{"patch":{"tags":["studio/shots"],"summary":"忽略镜头提取候选项","operationId":"ignore_extracted_candidate_api_v1_studio_shots_extracted_candidates__candidate_id__ignore_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-dialogue-candidates/{candidate_id}/accept":{"patch":{"tags":["studio/shots"],"summary":"接受镜头提取对白候选项","operationId":"accept_extracted_dialogue_candidate_api_v1_studio_shots_extracted_dialogue_candidates__candidate_id__accept_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"requestBody":{"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ShotExtractedDialogueCandidateAcceptRequest"},{"type":"null"}],"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-dialogue-candidates/{candidate_id}/ignore":{"patch":{"tags":["studio/shots"],"summary":"忽略镜头提取对白候选项","operationId":"ignore_extracted_dialogue_candidate_api_v1_studio_shots_extracted_dialogue_candidates__candidate_id__ignore_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}":{"get":{"tags":["studio/shots"],"summary":"获取镜头","operationId":"get_shot_api_v1_studio_shots__shot_id__get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/shots"],"summary":"更新镜头","operationId":"update_shot_api_v1_studio_shots__shot_id__patch","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shots"],"summary":"删除镜头","operationId":"delete_shot_api_v1_studio_shots__shot_id__delete","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/linked-assets":{"get":{"tags":["studio/shots"],"summary":"获取镜头关联的角色/道具/场景/服装(分页)","operationId":"list_shot_linked_assets_api_v1_studio_shots__shot_id__linked_assets_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotLinkedAssetItem__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-details":{"get":{"tags":["studio/shot-details"],"summary":"镜头细节列表(分页)","operationId":"list_shot_details_api_v1_studio_shot_details_get","parameters":[{"name":"shot_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按镜头过滤(id 同 shot_id)","title":"Shot Id"},"description":"按镜头过滤(id 同 shot_id)"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotDetailRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-details"],"summary":"创建镜头细节","operationId":"create_shot_detail_api_v1_studio_shot_details_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDetailCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-details/{shot_id}":{"get":{"tags":["studio/shot-details"],"summary":"获取镜头细节","operationId":"get_shot_detail_api_v1_studio_shot_details__shot_id__get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/shot-details"],"summary":"更新镜头细节","operationId":"update_shot_detail_api_v1_studio_shot_details__shot_id__patch","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDetailUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shot-details"],"summary":"删除镜头细节","operationId":"delete_shot_detail_api_v1_studio_shot_details__shot_id__delete","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-dialog-lines":{"get":{"tags":["studio/shot-dialog-lines"],"summary":"镜头对话行列表(分页)","operationId":"list_shot_dialog_lines_api_v1_studio_shot_dialog_lines_get","parameters":[{"name":"shot_detail_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按镜头细节过滤","title":"Shot Detail Id"},"description":"按镜头细节过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 text","title":"Q"},"description":"关键字,过滤 text"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotDialogLineRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-dialog-lines"],"summary":"创建镜头对话行","operationId":"create_shot_dialog_line_api_v1_studio_shot_dialog_lines_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDialogLineCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDialogLineRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-dialog-lines/{line_id}":{"patch":{"tags":["studio/shot-dialog-lines"],"summary":"更新镜头对话行","operationId":"update_shot_dialog_line_api_v1_studio_shot_dialog_lines__line_id__patch","parameters":[{"name":"line_id","in":"path","required":true,"schema":{"type":"integer","title":"Line Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDialogLineUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDialogLineRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shot-dialog-lines"],"summary":"删除镜头对话行","operationId":"delete_shot_dialog_line_api_v1_studio_shot_dialog_lines__line_id__delete","parameters":[{"name":"line_id","in":"path","required":true,"schema":{"type":"integer","title":"Line Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/{entity_type}":{"get":{"tags":["studio/shot-links"],"summary":"项目-章节-镜头-实体关联列表(分页)","operationId":"list_project_entity_links_api_v1_studio_shot_links__entity_type__get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"project_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"}},{"name":"chapter_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"}},{"name":"shot_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"}},{"name":"asset_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Asset Id"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/actor":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-演员关联","operationId":"create_project_actor_link_api_v1_studio_shot_links_actor_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectActorLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/actor/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-演员关联","operationId":"delete_project_actor_link_api_v1_studio_shot_links_actor__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/scene":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-场景关联","operationId":"create_project_scene_link_api_v1_studio_shot_links_scene_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectSceneLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/scene/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-场景关联","operationId":"delete_project_scene_link_api_v1_studio_shot_links_scene__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/prop":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-道具关联","operationId":"create_project_prop_link_api_v1_studio_shot_links_prop_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectPropLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/prop/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-道具关联","operationId":"delete_project_prop_link_api_v1_studio_shot_links_prop__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/costume":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-服装关联","operationId":"create_project_costume_link_api_v1_studio_shot_links_costume_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectCostumeLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/costume/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-服装关联","operationId":"delete_project_costume_link_api_v1_studio_shot_links_costume__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-frame-images":{"get":{"tags":["studio/shot-frame-images"],"summary":"镜头分镜帧图片列表(分页)","operationId":"list_shot_frame_images_api_v1_studio_shot_frame_images_get","parameters":[{"name":"shot_detail_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按镜头细节过滤","title":"Shot Detail Id"},"description":"按镜头细节过滤"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotFrameImageRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-frame-images"],"summary":"创建镜头分镜帧图片","operationId":"create_shot_frame_image_api_v1_studio_shot_frame_images_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFrameImageCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotFrameImageRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-frame-images/{image_id}":{"patch":{"tags":["studio/shot-frame-images"],"summary":"更新镜头分镜帧图片","operationId":"update_shot_frame_image_api_v1_studio_shot_frame_images__image_id__patch","parameters":[{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFrameImageUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotFrameImageRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shot-frame-images"],"summary":"删除镜头分镜帧图片","operationId":"delete_shot_frame_image_api_v1_studio_shot_frame_images__image_id__delete","parameters":[{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/existence-check":{"post":{"tags":["studio/entities"],"summary":"批量检测资产名称是否存在(模糊匹配,不分页)","operationId":"check_entity_names_existence_api_v1_studio_entities_existence_check_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityNameExistenceCheckRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_EntityNameExistenceCheckResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}":{"get":{"tags":["studio/entities"],"summary":"统一实体列表(分页)","operationId":"list_entities_api_v1_studio_entities__entity_type__get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"style","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"题材/风格(单值)","title":"Style"},"description":"题材/风格(单值)"},{"name":"visual_style","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"画面表现形式(单值:真人/动漫)","title":"Visual Style"},"description":"画面表现形式(单值:真人/动漫)"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_dict_str__Any___"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/entities"],"summary":"统一创建实体","operationId":"create_entity_api_v1_studio_entities__entity_type__post","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}/{entity_id}":{"get":{"tags":["studio/entities"],"summary":"统一获取实体","operationId":"get_entity_api_v1_studio_entities__entity_type___entity_id__get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/entities"],"summary":"统一更新实体","operationId":"update_entity_api_v1_studio_entities__entity_type___entity_id__patch","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/entities"],"summary":"统一删除实体","operationId":"delete_entity_api_v1_studio_entities__entity_type___entity_id__delete","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}/{entity_id}/images":{"get":{"tags":["studio/entities"],"summary":"统一实体图片列表(分页)","operationId":"list_entity_images_api_v1_studio_entities__entity_type___entity_id__images_get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_dict_str__Any___"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/entities"],"summary":"统一创建实体图片","operationId":"create_entity_image_api_v1_studio_entities__entity_type___entity_id__images_post","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}/{entity_id}/images/{image_id}":{"patch":{"tags":["studio/entities"],"summary":"统一更新实体图片","operationId":"update_entity_image_api_v1_studio_entities__entity_type___entity_id__images__image_id__patch","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}},{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/entities"],"summary":"统一删除实体图片","operationId":"delete_entity_image_api_v1_studio_entities__entity_type___entity_id__images__image_id__delete","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}},{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/prompts":{"get":{"tags":["studio/prompts"],"summary":"提示词模板列表(分页)","operationId":"list_prompt_templates_api_v1_studio_prompts_get","parameters":[{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/PromptCategory"},{"type":"null"}],"description":"按类别过滤","title":"Category"},"description":"按类别过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name","title":"Q"},"description":"关键字,过滤 name"},{"name":"is_default","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"过滤是否为默认","title":"Is Default"},"description":"过滤是否为默认"},{"name":"is_system","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"过滤是否为系统预置","title":"Is System"},"description":"过滤是否为系统预置"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_PromptTemplateRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/prompts"],"summary":"创建提示词模板","operationId":"create_prompt_template_api_v1_studio_prompts_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptTemplateCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PromptTemplateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/prompts/categories":{"get":{"tags":["studio/prompts"],"summary":"获取提示词类别枚举(含中文映射)","operationId":"list_prompt_categories_api_v1_studio_prompts_categories_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_PromptCategoryOptionRead__"}}}}}}},"/api/v1/studio/prompts/{template_id}":{"get":{"tags":["studio/prompts"],"summary":"获取提示词模板详情","operationId":"get_prompt_template_api_v1_studio_prompts__template_id__get","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","title":"Template Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PromptTemplateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/prompts"],"summary":"局部更新提示词模板","operationId":"update_prompt_template_api_v1_studio_prompts__template_id__patch","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","title":"Template Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptTemplateUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PromptTemplateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/prompts"],"summary":"删除提示词模板","operationId":"delete_prompt_template_api_v1_studio_prompts__template_id__delete","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","title":"Template Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files":{"get":{"tags":["studio/files"],"summary":"文件列表(分页)","operationId":"list_files_api_api_v1_studio_files_get","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name","title":"Q"},"description":"关键字,过滤 name"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}},{"name":"project_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 file_usages 限定项目;提供后仅返回该项目下有关联记录的文件","title":"Project Id"},"description":"按 file_usages 限定项目;提供后仅返回该项目下有关联记录的文件"},{"name":"chapter_title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"章节标题(精确匹配,与 project_id 联用)","title":"Chapter Title"},"description":"章节标题(精确匹配,与 project_id 联用)"},{"name":"shot_title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"镜头标题(精确匹配,与 project_id 联用)","title":"Shot Title"},"description":"镜头标题(精确匹配,与 project_id 联用)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_FileRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/upload":{"post":{"tags":["studio/files"],"summary":"上传文件并创建 FileItem 记录","operationId":"upload_file_api_api_v1_studio_files_upload_post","parameters":[{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_file_api_api_v1_studio_files_upload_post"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_FileRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/{file_id}/download":{"get":{"tags":["studio/files"],"summary":"下载文件二进制内容","operationId":"download_file_api_api_v1_studio_files__file_id__download_get","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/{file_id}/storage-info":{"get":{"tags":["studio/files"],"summary":"获取对象存储详情(head_object)","operationId":"get_file_storage_info_api_api_v1_studio_files__file_id__storage_info_get","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/{file_id}":{"get":{"tags":["studio/files"],"summary":"获取文件详情(元信息 + file_usages)","operationId":"get_file_detail_api_v1_studio_files__file_id__get","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_FileDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/files"],"summary":"更新文件元信息","operationId":"update_file_meta_api_v1_studio_files__file_id__patch","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_FileRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/files"],"summary":"删除文件(记录 + 存储对象)","operationId":"delete_file_api_api_v1_studio_files__file_id__delete","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/actors/{actor_id}/image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"演员图片生成(任务版)","description":"为指定演员创建图片生成任务,并通过 `GenerationTaskLink` 关联。","operationId":"create_actor_image_generation_task_api_v1_studio_image_tasks_actors__actor_id__image_tasks_post","parameters":[{"name":"actor_id","in":"path","required":true,"schema":{"type":"string","title":"Actor Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/actors/{actor_id}/render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"演员图片提示词渲染","operationId":"render_actor_image_prompt_api_v1_studio_image_tasks_actors__actor_id__render_prompt_post","parameters":[{"name":"actor_id","in":"path","required":true,"schema":{"type":"string","title":"Actor Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedPromptResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/assets/{asset_type}/{asset_id}/image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"道具/场景/服装图片生成(任务版)","description":"为道具/场景/服装创建图片生成任务。\n\n- asset_type: prop / scene / costume\n- path 参数 asset_id 为对应资产 ID\n- body.image_id 必须为该资产下对应图片表记录的 ID(PropImage/SceneImage/CostumeImage)","operationId":"create_asset_image_generation_task_api_v1_studio_image_tasks_assets__asset_type___asset_id__image_tasks_post","parameters":[{"name":"asset_type","in":"path","required":true,"schema":{"type":"string","title":"Asset Type"}},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string","title":"Asset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/assets/{asset_type}/{asset_id}/render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"道具/场景/服装图片提示词渲染","operationId":"render_asset_image_prompt_api_v1_studio_image_tasks_assets__asset_type___asset_id__render_prompt_post","parameters":[{"name":"asset_type","in":"path","required":true,"schema":{"type":"string","title":"Asset Type"}},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string","title":"Asset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedPromptResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/characters/{character_id}/image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"角色图片生成(任务版)","description":"为角色创建图片生成任务(对应 CharacterImage 业务)。\n\n- path 参数 character_id 为 Character.id\n- body.image_id 必须为该角色下的 CharacterImage.id","operationId":"create_character_image_generation_task_api_v1_studio_image_tasks_characters__character_id__image_tasks_post","parameters":[{"name":"character_id","in":"path","required":true,"schema":{"type":"string","title":"Character Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/characters/{character_id}/render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"角色图片提示词渲染","operationId":"render_character_image_prompt_api_v1_studio_image_tasks_characters__character_id__render_prompt_post","parameters":[{"name":"character_id","in":"path","required":true,"schema":{"type":"string","title":"Character Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedPromptResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/shot/{shot_id}/frame-image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"镜头分镜帧图片生成(任务版)","description":"为镜头分镜帧图片生成任务(基于 `shot_id + frame_type` 自动定位数据)。","operationId":"create_shot_frame_image_generation_task_api_v1_studio_image_tasks_shot__shot_id__frame_image_tasks_post","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFrameImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/shot/{shot_id}/frame-render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"镜头分镜帧提示词渲染","operationId":"render_shot_frame_prompt_api_v1_studio_image_tasks_shot__shot_id__frame_render_prompt_post","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFramePromptRenderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedShotFramePromptRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-character-links":{"get":{"tags":["studio/shot-character-links"],"summary":"查询镜头角色关联列表(ShotCharacterLink)","operationId":"list_shot_character_links_api_v1_studio_shot_character_links_get","parameters":[{"name":"shot_id","in":"query","required":true,"schema":{"type":"string","description":"镜头 ID","title":"Shot Id"},"description":"镜头 ID"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotCharacterLinkRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-character-links"],"summary":"创建/更新镜头角色关联(ShotCharacterLink)","operationId":"upsert_shot_character_link_api_v1_studio_shot_character_links_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotCharacterLinkCreate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotCharacterLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/divide-async":{"post":{"tags":["script-processing"],"summary":"异步将剧本分割为多个镜头","description":"创建章节分镜提取任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"divide_script_async_api_v1_script_processing_divide_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptDividerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/divide":{"post":{"tags":["script-processing"],"summary":"将剧本分割为多个镜头","description":"输入完整剧本文本,输出分镜列表(index/start_line/end_line/script_excerpt/shot_name/time_of_day)。注意:此阶段不强制稳定ID,角色以“称呼/名字”弱信息输出,稳定ID在合并阶段统一分配。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 divide-async。","operationId":"divide_script_api_v1_script_processing_divide_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptDividerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptDivisionResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/merge-entities-async":{"post":{"tags":["script-processing"],"summary":"异步合并多镜头的实体信息","description":"创建实体合并任务并立即返回 task_id;当前保留为预备能力,尚无真实前端入口。","operationId":"merge_entities_async_api_v1_script_processing_merge_entities_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityMergerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/merge-entities":{"post":{"tags":["script-processing"],"summary":"合并多镜头的实体信息","description":"输入全部分镜提取结果(可选带上脚本分镜与历史实体库),输出合并后的实体库:角色库/地点库/场景库/道具库(静态画像 + 变体列表)。该步骤会统一分配稳定ID(如 char_001/loc_001/prop_001/scene_001)。当提供 previous_merge 与 conflict_resolutions 时,将进行冲突重试合并,优先消解 conflicts 并尽量保持 ID 稳定。当前接口保留为预备能力,尚无真实前端入口。","operationId":"merge_entities_api_v1_script_processing_merge_entities_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityMergerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_EntityMergeResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-variants-async":{"post":{"tags":["script-processing"],"summary":"异步分析服装/外形变体","description":"创建变体分析任务并立即返回 task_id;当前保留为预备能力,尚无真实前端入口。","operationId":"analyze_variants_async_api_v1_script_processing_analyze_variants_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VariantAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-variants":{"post":{"tags":["script-processing"],"summary":"分析服装/外形变体","description":"检测角色服装/外形变化,构建演变时间线,生成章节变体建议列表与变体建议。当前接口保留为预备能力,尚无真实前端入口。","operationId":"analyze_variants_api_v1_script_processing_analyze_variants_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VariantAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VariantAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/check-consistency-async":{"post":{"tags":["script-processing"],"summary":"异步检查角色混淆一致性(基于原文)","description":"创建一致性检查任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"check_consistency_async_api_v1_script_processing_check_consistency_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptConsistencyCheckRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/check-consistency":{"post":{"tags":["script-processing"],"summary":"检查角色混淆一致性(基于原文)","description":"检测同一角色在不同段落/镜头被赋予不同身份/行为主体导致混淆,并给出修改建议。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 check-consistency-async。","operationId":"check_consistency_api_v1_script_processing_check_consistency_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptConsistencyCheckRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptConsistencyCheckResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-character-portrait-async":{"post":{"tags":["script-processing"],"summary":"异步分析人物画像缺失信息","description":"创建人物画像分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_character_portrait_async_api_v1_script_processing_analyze_character_portrait_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CharacterPortraitAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-character-portrait":{"post":{"tags":["script-processing"],"summary":"分析人物画像缺失信息","description":"根据原文人物上下文与人物描述,判断缺少哪些关键信息,并给出优化后的人物画像描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-character-portrait-async。","operationId":"analyze_character_portrait_api_v1_script_processing_analyze_character_portrait_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CharacterPortraitAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_CharacterPortraitAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-prop-info-async":{"post":{"tags":["script-processing"],"summary":"异步分析道具信息缺失项","description":"创建道具信息分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_prop_info_async_api_v1_script_processing_analyze_prop_info_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-prop-info":{"post":{"tags":["script-processing"],"summary":"分析道具信息缺失项","description":"根据原文道具上下文与道具描述,判断缺少哪些关键信息,并给出优化后的可生成道具描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-prop-info-async。","operationId":"analyze_prop_info_api_v1_script_processing_analyze_prop_info_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PropInfoAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-scene-info-async":{"post":{"tags":["script-processing"],"summary":"异步分析场景信息缺失项","description":"创建场景信息分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_scene_info_async_api_v1_script_processing_analyze_scene_info_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SceneInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-scene-info":{"post":{"tags":["script-processing"],"summary":"分析场景信息缺失项","description":"根据原文场景上下文与场景描述,判断缺少哪些关键信息,并给出优化后的可生成场景描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-scene-info-async。","operationId":"analyze_scene_info_api_v1_script_processing_analyze_scene_info_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SceneInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_SceneInfoAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-costume-info-async":{"post":{"tags":["script-processing"],"summary":"异步分析服装信息缺失项","description":"创建服装信息分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_costume_info_async_api_v1_script_processing_analyze_costume_info_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CostumeInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-costume-info":{"post":{"tags":["script-processing"],"summary":"分析服装信息缺失项","description":"根据原文服装上下文与服装描述,判断缺少哪些关键信息,并给出优化后的可生成服装描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-costume-info-async。","operationId":"analyze_costume_info_api_v1_script_processing_analyze_costume_info_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CostumeInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_CostumeInfoAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/optimize-script-async":{"post":{"tags":["script-processing"],"summary":"异步基于一致性检查优化剧本","description":"创建剧本优化任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"optimize_script_async_api_v1_script_processing_optimize_script_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptOptimizeRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/optimize-script":{"post":{"tags":["script-processing"],"summary":"基于一致性检查优化剧本","description":"将一致性检查输出及原文作为输入,生成优化后的剧本(尽量少改,只改与角色混淆 issues 相关段落)。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 optimize-script-async。","operationId":"optimize_script_api_v1_script_processing_optimize_script_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptOptimizeRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptOptimizationResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/simplify-script":{"post":{"tags":["script-processing"],"summary":"智能精简剧本","description":"在保留剧情主体并保证剧情连续的前提下精简剧本文本。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 simplify-script-async。","operationId":"simplify_script_api_v1_script_processing_simplify_script_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptSimplifyRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptSimplificationResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/simplify-script-async":{"post":{"tags":["script-processing"],"summary":"异步智能精简剧本","description":"创建剧本精简任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"simplify_script_async_api_v1_script_processing_simplify_script_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptSimplifyRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/extract-async":{"post":{"tags":["script-processing"],"summary":"异步项目级信息提取(最终输出)","description":"创建项目级信息提取任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"extract_script_async_api_v1_script_processing_extract_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptExtractRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/extract":{"post":{"tags":["script-processing"],"summary":"项目级信息提取(最终输出)","description":"输入分镜结果(可选带一致性检查结果),输出可导入 Studio 的草稿结构(name-based,ID 由导入接口生成)。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 extract-async。","operationId":"extract_script_api_v1_script_processing_extract_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptExtractRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_StudioScriptExtractionDraft_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"summary":"Health","description":"健康检查。","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"ActionBeatPhaseRead":{"properties":{"text":{"type":"string","title":"Text","description":"动作拍点原文"},"phase":{"type":"string","enum":["trigger","peak","aftermath"],"title":"Phase","description":"推断阶段:触发 / 峰值 / 收束"}},"type":"object","required":["text","phase"],"title":"ActionBeatPhaseRead","description":"动作拍点的轻量阶段推断结果。"},"ApiResponse_AsyncTaskCreateRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/AsyncTaskCreateRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[AsyncTaskCreateRead]"},"ApiResponse_ChapterRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ChapterRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ChapterRead]"},"ApiResponse_CharacterPortraitAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/CharacterPortraitAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[CharacterPortraitAnalysisResult]"},"ApiResponse_CostumeInfoAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/CostumeInfoAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[CostumeInfoAnalysisResult]"},"ApiResponse_EntityMergeResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/EntityMergeResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[EntityMergeResult]"},"ApiResponse_EntityNameExistenceCheckResponse_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/EntityNameExistenceCheckResponse"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[EntityNameExistenceCheckResponse]"},"ApiResponse_FileDetailRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/FileDetailRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[FileDetailRead]"},"ApiResponse_FileRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/FileRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[FileRead]"},"ApiResponse_GenerationTaskLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/GenerationTaskLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[GenerationTaskLinkRead]"},"ApiResponse_ImageGenerationOptionsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ImageGenerationOptionsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ImageGenerationOptionsRead]"},"ApiResponse_ModelRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ModelRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ModelRead]"},"ApiResponse_ModelSettingsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ModelSettingsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ModelSettingsRead]"},"ApiResponse_NoneType_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"type":"null","title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[NoneType]"},"ApiResponse_PaginatedData_Any__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_Any_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[Any]]"},"ApiResponse_PaginatedData_ChapterRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ChapterRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ChapterRead]]"},"ApiResponse_PaginatedData_FileRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_FileRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[FileRead]]"},"ApiResponse_PaginatedData_GenerationTaskLinkRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_GenerationTaskLinkRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[GenerationTaskLinkRead]]"},"ApiResponse_PaginatedData_ModelRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ModelRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ModelRead]]"},"ApiResponse_PaginatedData_ProjectRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ProjectRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ProjectRead]]"},"ApiResponse_PaginatedData_PromptTemplateRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_PromptTemplateRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[PromptTemplateRead]]"},"ApiResponse_PaginatedData_ProviderRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ProviderRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ProviderRead]]"},"ApiResponse_PaginatedData_ShotDetailRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotDetailRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotDetailRead]]"},"ApiResponse_PaginatedData_ShotDialogLineRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotDialogLineRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotDialogLineRead]]"},"ApiResponse_PaginatedData_ShotFrameImageRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotFrameImageRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotFrameImageRead]]"},"ApiResponse_PaginatedData_ShotLinkedAssetItem__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotLinkedAssetItem_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotLinkedAssetItem]]"},"ApiResponse_PaginatedData_ShotRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotRead]]"},"ApiResponse_PaginatedData_TaskListItemRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_TaskListItemRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[TaskListItemRead]]"},"ApiResponse_PaginatedData_dict_str__Any___":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_dict_str__Any__"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[dict[str, Any]]]"},"ApiResponse_ProjectActorLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectActorLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectActorLinkRead]"},"ApiResponse_ProjectCostumeLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectCostumeLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectCostumeLinkRead]"},"ApiResponse_ProjectPropLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectPropLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectPropLinkRead]"},"ApiResponse_ProjectRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectRead]"},"ApiResponse_ProjectSceneLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectSceneLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectSceneLinkRead]"},"ApiResponse_ProjectStyleOptionsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectStyleOptionsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectStyleOptionsRead]"},"ApiResponse_PromptTemplateRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PromptTemplateRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PromptTemplateRead]"},"ApiResponse_PropInfoAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PropInfoAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PropInfoAnalysisResult]"},"ApiResponse_ProviderRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProviderRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProviderRead]"},"ApiResponse_RenderedPromptResponse_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/RenderedPromptResponse"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[RenderedPromptResponse]"},"ApiResponse_RenderedShotFramePromptRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/RenderedShotFramePromptRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[RenderedShotFramePromptRead]"},"ApiResponse_SceneInfoAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/SceneInfoAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[SceneInfoAnalysisResult]"},"ApiResponse_ScriptConsistencyCheckResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptConsistencyCheckResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptConsistencyCheckResult]"},"ApiResponse_ScriptDivisionResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptDivisionResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptDivisionResult]"},"ApiResponse_ScriptOptimizationResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptOptimizationResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptOptimizationResult]"},"ApiResponse_ScriptSimplificationResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptSimplificationResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptSimplificationResult]"},"ApiResponse_ShotAssetsOverviewRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotAssetsOverviewRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotAssetsOverviewRead]"},"ApiResponse_ShotCharacterLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotCharacterLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotCharacterLinkRead]"},"ApiResponse_ShotDetailRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotDetailRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotDetailRead]"},"ApiResponse_ShotDialogLineRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotDialogLineRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotDialogLineRead]"},"ApiResponse_ShotFrameImageRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotFrameImageRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotFrameImageRead]"},"ApiResponse_ShotPreparationMutationResultRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotPreparationMutationResultRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotPreparationMutationResultRead]"},"ApiResponse_ShotPreparationStateRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotPreparationStateRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotPreparationStateRead]"},"ApiResponse_ShotRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotRead]"},"ApiResponse_ShotVideoPromptPreviewRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotVideoPromptPreviewRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotVideoPromptPreviewRead]"},"ApiResponse_ShotVideoReadinessRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotVideoReadinessRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotVideoReadinessRead]"},"ApiResponse_StudioScriptExtractionDraft_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/StudioScriptExtractionDraft"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[StudioScriptExtractionDraft]"},"ApiResponse_TaskCancelRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskCancelRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskCancelRead]"},"ApiResponse_TaskCreated_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskCreated"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskCreated]"},"ApiResponse_TaskLinkAdoptRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskLinkAdoptRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskLinkAdoptRead]"},"ApiResponse_TaskResultRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskResultRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskResultRead]"},"ApiResponse_TaskStatusRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskStatusRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskStatusRead]"},"ApiResponse_VariantAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/VariantAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[VariantAnalysisResult]"},"ApiResponse_VideoGenerationOptionsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/VideoGenerationOptionsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[VideoGenerationOptionsRead]"},"ApiResponse_VideoPromptPreviewResponse_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/VideoPromptPreviewResponse"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[VideoPromptPreviewResponse]"},"ApiResponse_dict_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[dict]"},"ApiResponse_dict_str__Any__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[dict[str, Any]]"},"ApiResponse_list_PromptCategoryOptionRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/PromptCategoryOptionRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[PromptCategoryOptionRead]]"},"ApiResponse_list_ProviderSupportedRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ProviderSupportedRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ProviderSupportedRead]]"},"ApiResponse_list_ShotCharacterLinkRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotCharacterLinkRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotCharacterLinkRead]]"},"ApiResponse_list_ShotExtractedCandidateRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotExtractedCandidateRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotExtractedCandidateRead]]"},"ApiResponse_list_ShotExtractedDialogueCandidateRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotExtractedDialogueCandidateRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotExtractedDialogueCandidateRead]]"},"ApiResponse_list_ShotRuntimeSummaryRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotRuntimeSummaryRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotRuntimeSummaryRead]]"},"AsyncTaskCreateRead":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"任务 ID"},"status":{"$ref":"#/components/schemas/TaskStatus","description":"任务状态"},"reused":{"type":"boolean","title":"Reused","description":"是否复用了当前业务实体已有的活跃任务","default":false},"relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Type","description":"业务关联类型"},"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"业务关联实体 ID"}},"type":"object","required":["task_id","status"],"title":"AsyncTaskCreateRead"},"Body_upload_file_api_api_v1_studio_files_upload_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File","description":"要上传的二进制文件"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"可选:写入 file_usages 的项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"},"usage_kind":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Usage Kind","description":"与 project_id 同时提供时写入 file_usages"},"source_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Ref"}},"type":"object","required":["file"],"title":"Body_upload_file_api_api_v1_studio_files_upload_post"},"CameraAngle":{"type":"string","enum":["EYE_LEVEL","HIGH_ANGLE","LOW_ANGLE","BIRD_EYE","DUTCH","OVER_SHOULDER"],"title":"CameraAngle","description":"机位角度(与 `app.schemas.skills.common.CameraAngle` 对齐,存英文 code)。"},"CameraMovement":{"type":"string","enum":["STATIC","PAN","TILT","DOLLY_IN","DOLLY_OUT","TRACK","CRANE","HANDHELD","STEADICAM","ZOOM_IN","ZOOM_OUT"],"title":"CameraMovement","description":"运镜方式(与 `app.schemas.skills.common.CameraMovement` 对齐,存英文 code)。"},"CameraShotType":{"type":"string","enum":["ECU","CU","MCU","MS","MLS","LS","ELS"],"title":"CameraShotType","description":"景别(与 `app.schemas.skills.common.ShotType` 对齐,存英文 code)。"},"ChapterCreate":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"所属项目 ID"},"index":{"type":"integer","title":"Index","description":"章节序号(项目内唯一)"},"title":{"type":"string","title":"Title","description":"章节标题"},"summary":{"type":"string","title":"Summary","description":"章节摘要","default":""},"raw_text":{"type":"string","title":"Raw Text","description":"章节原文","default":""},"condensed_text":{"type":"string","title":"Condensed Text","description":"精简原文","default":""},"storyboard_count":{"type":"integer","title":"Storyboard Count","description":"分镜数量","default":0},"status":{"$ref":"#/components/schemas/ChapterStatus","description":"章节状态","default":"draft"},"id":{"type":"string","title":"Id","description":"章节 ID"}},"type":"object","required":["project_id","index","title","id"],"title":"ChapterCreate"},"ChapterRead":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"所属项目 ID"},"index":{"type":"integer","title":"Index","description":"章节序号(项目内唯一)"},"title":{"type":"string","title":"Title","description":"章节标题"},"summary":{"type":"string","title":"Summary","description":"章节摘要","default":""},"raw_text":{"type":"string","title":"Raw Text","description":"章节原文","default":""},"condensed_text":{"type":"string","title":"Condensed Text","description":"精简原文","default":""},"storyboard_count":{"type":"integer","title":"Storyboard Count","description":"分镜数量","default":0},"status":{"$ref":"#/components/schemas/ChapterStatus","description":"章节状态","default":"draft"},"id":{"type":"string","title":"Id"},"shot_count":{"type":"integer","title":"Shot Count","description":"分镜数(shots 条数聚合)","default":0}},"type":"object","required":["project_id","index","title","id"],"title":"ChapterRead"},"ChapterStatus":{"type":"string","enum":["draft","shooting","done"],"title":"ChapterStatus","description":"章节生产状态。"},"ChapterUpdate":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"},"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"raw_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Raw Text"},"condensed_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Condensed Text"},"storyboard_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Storyboard Count"},"status":{"anyOf":[{"$ref":"#/components/schemas/ChapterStatus"},{"type":"null"}]}},"type":"object","title":"ChapterUpdate"},"CharacterPortraitAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"character_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Character Context","description":"原文人物上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"character_description":{"type":"string","minLength":1,"title":"Character Description","description":"原文人物描述"}},"type":"object","required":["character_description"],"title":"CharacterPortraitAnalysisRequest","description":"人物画像缺失信息分析请求。"},"CharacterPortraitAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"CharacterPortraitAnalysisResult","description":"根据原文人物描述,分析缺少的信息,并给出优化后的可生成画像描述。"},"CostumeInfoAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"costume_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Context","description":"原文服装上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"costume_description":{"type":"string","minLength":1,"title":"Costume Description","description":"原文服装描述"}},"type":"object","required":["costume_description"],"title":"CostumeInfoAnalysisRequest","description":"服装信息缺失分析请求。"},"CostumeInfoAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"CostumeInfoAnalysisResult","description":"根据原文服装/造型描述,分析缺少的信息,并给出优化后的可生成服装描述。"},"CostumeTimeline":{"properties":{"character_id":{"type":"string","title":"Character Id","description":"角色稳定ID"},"character_name":{"type":"string","title":"Character Name","description":"角色名称"},"timeline_entries":{"items":{"$ref":"#/components/schemas/CostumeTimelineEntry"},"type":"array","title":"Timeline Entries","description":"时间线条目"}},"additionalProperties":false,"type":"object","required":["character_id","character_name"],"title":"CostumeTimeline","description":"单角色的服装演变时间线。"},"CostumeTimelineEntry":{"properties":{"shot_index":{"type":"integer","minimum":1.0,"title":"Shot Index","description":"镜头序号"},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id","description":"可选:所属场景稳定ID(若已可推断)"},"costume_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Note","description":"服装/外形要点(简短)"},"changes":{"items":{"type":"string"},"type":"array","title":"Changes","description":"与上一条相比的变化点"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["shot_index"],"title":"CostumeTimelineEntry","description":"单角色的服装演变时间线条目。"},"DialogueLineMode":{"type":"string","enum":["DIALOGUE","VOICE_OVER","OFF_SCREEN","PHONE"],"title":"DialogueLineMode","description":"对白模式(与 `app.schemas.skills.common.DialogueLineMode` 对齐,存英文 code)。"},"EntityEntry":{"properties":{"id":{"type":"string","title":"Id","description":"实体稳定ID(合并阶段分配)"},"name":{"type":"string","title":"Name","description":"实体名称"},"type":{"type":"string","enum":["character","scene","prop","location"],"title":"Type","description":"实体类型"},"normalized_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Normalized Name","description":"归一化名称(来自文本,可选)"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases","description":"别名/称呼(来自文本,可选)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"基础画像/描述(忠实文本,简短)"},"confidence":{"anyOf":[{"type":"number","maximum":1.0,"minimum":0.0},{"type":"null"}],"title":"Confidence","description":"合并确定度 0-1(可选)"},"first_appearance":{"anyOf":[{"$ref":"#/components/schemas/EvidenceSpan"},{"type":"null"}],"description":"首次出场证据(可选)"},"costume_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Note","description":"服装/造型描述(可选,便于变体与资产关联)"},"traits":{"items":{"type":"string"},"type":"array","title":"Traits","description":"性格/特征词(可选)"},"location_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location Type","description":"地点类型:房间/街道/森林/车厢等(可选)"},"category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category","description":"道具类别(可选:weapon/document/vehicle/clothing/device/magic_item/other)"},"owner_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Character Id","description":"拥有者角色ID(可选)"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"支撑该实体画像的证据片段(可选)"},"first_shot":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Shot","description":"首次出现的镜头序号"},"appearances":{"items":{"type":"integer"},"type":"array","title":"Appearances","description":"出现镜头列表"},"variants":{"items":{"$ref":"#/components/schemas/EntityVariant"},"type":"array","title":"Variants","description":"变体列表"}},"additionalProperties":false,"type":"object","required":["id","name","type"],"title":"EntityEntry","description":"合并后的实体条目(脚本处理中间态)。"},"EntityLibrary":{"properties":{"characters":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Characters","description":"角色库"},"locations":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Locations","description":"地点库"},"scenes":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Scenes","description":"场景库"},"props":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Props","description":"道具库"},"total_entries":{"type":"integer","minimum":0.0,"title":"Total Entries","description":"总实体数"}},"additionalProperties":false,"type":"object","required":["total_entries"],"title":"EntityLibrary","description":"合并后的实体库(脚本处理中间态)。"},"EntityMergeResult":{"properties":{"merged_library":{"$ref":"#/components/schemas/EntityLibrary","description":"合并后的实体库"},"merge_stats":{"additionalProperties":true,"type":"object","title":"Merge Stats","description":"合并统计信息"},"conflicts":{"items":{"type":"string"},"type":"array","title":"Conflicts","description":"发现的冲突/待处理项"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"合并说明"}},"additionalProperties":false,"type":"object","required":["merged_library"],"title":"EntityMergeResult","description":"实体合并结果(脚本处理中间态)。"},"EntityMergerRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"all_shot_extractions":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"All Shot Extractions","description":"所有镜头提取结果(ShotElementExtractionResult 的序列化形式)"},"historical_library":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Historical Library","description":"历史实体库(可选,用于增量合并)"},"script_division":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Script Division","description":"脚本分镜结果(可选;ScriptDivisionResult 序列化),用于定位与统计"},"previous_merge":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Previous Merge","description":"上一次合并结果(可选;EntityMergeResult 序列化),用于冲突重试合并"},"conflict_resolutions":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Conflict Resolutions","description":"冲突解决建议列表(可选;用于冲突重试合并)"}},"type":"object","required":["all_shot_extractions"],"title":"EntityMergerRequest","description":"实体合并请求。"},"EntityNameExistenceCheckRequest":{"properties":{"project_id":{"type":"string","minLength":1,"title":"Project Id","description":"项目 ID(必填)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可选;不传则 linked_to_shot 恒为 false)"},"character_names":{"items":{"type":"string"},"type":"array","title":"Character Names","description":"角色名称列表"},"prop_names":{"items":{"type":"string"},"type":"array","title":"Prop Names","description":"道具名称列表"},"scene_names":{"items":{"type":"string"},"type":"array","title":"Scene Names","description":"场景名称列表"},"costume_names":{"items":{"type":"string"},"type":"array","title":"Costume Names","description":"服装名称列表"}},"additionalProperties":false,"type":"object","required":["project_id"],"title":"EntityNameExistenceCheckRequest","description":"批量检测项目内/全局资产名称是否存在(模糊匹配)。"},"EntityNameExistenceCheckResponse":{"properties":{"characters":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Characters"},"props":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Props"},"scenes":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Scenes"},"costumes":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Costumes"}},"additionalProperties":false,"type":"object","title":"EntityNameExistenceCheckResponse","description":"批量存在性检测结果(按资产类型分组)。"},"EntityNameExistenceItem":{"properties":{"name":{"type":"string","title":"Name","description":"输入名称(原样回传)"},"exists":{"type":"boolean","title":"Exists","description":"数据库中是否存在(模糊命中)"},"linked_to_project":{"type":"boolean","title":"Linked To Project","description":"是否已关联到该项目(角色等同于 exists)"},"linked_to_shot":{"type":"boolean","title":"Linked To Shot","description":"是否已关联到请求中的 shot(未传 shot_id 时为 false)","default":false},"asset_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Asset Id","description":"命中的资产 ID(如 prop_id/scene_id/costume_id/character_id)"},"link_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Link Id","description":"若已关联到项目,对应 Project*Link 的 id;否则为空"}},"additionalProperties":false,"type":"object","required":["name","exists","linked_to_project"],"title":"EntityNameExistenceItem","description":"单个名称的存在性结果。"},"EntityVariant":{"properties":{"variant_key":{"type":"string","title":"Variant Key","description":"变体键(例如 outfit_v1、wounded_state 等)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"变体描述(简短)"},"affected_shots":{"items":{"type":"integer"},"type":"array","title":"Affected Shots","description":"涉及镜头序号"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["variant_key"],"title":"EntityVariant","description":"实体变体条目(最小可用结构,便于服装/外形演变)。"},"EvidenceSpan":{"properties":{"chunk_id":{"type":"string","title":"Chunk Id","description":"输入文本块的唯一ID(例如 chapter1_p03)"},"start_char":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Start Char","description":"在该 chunk 中的起始字符位置(可选)"},"end_char":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"End Char","description":"在该 chunk 中的结束字符位置(可选)"},"quote":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Quote","description":"不超过200字的原文摘录(可选,便于人工审核)"}},"additionalProperties":false,"type":"object","required":["chunk_id"],"title":"EvidenceSpan","description":"可追溯证据:原文定位(chunk + 起止位置/摘录),用于审核与回查。"},"FileDetailRead":{"properties":{"id":{"type":"string","title":"Id","description":"文件 ID"},"type":{"$ref":"#/components/schemas/FileTypeEnum","description":"文件类型"},"name":{"type":"string","title":"Name","description":"文件名/标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"},"usages":{"items":{"$ref":"#/components/schemas/FileUsageRead"},"type":"array","title":"Usages"}},"type":"object","required":["id","type","name"],"title":"FileDetailRead","description":"含 file_usages 列表(详情接口)。"},"FileRead":{"properties":{"id":{"type":"string","title":"Id","description":"文件 ID"},"type":{"$ref":"#/components/schemas/FileTypeEnum","description":"文件类型"},"name":{"type":"string","title":"Name","description":"文件名/标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"}},"type":"object","required":["id","type","name"],"title":"FileRead"},"FileTypeEnum":{"type":"string","enum":["image","video"],"title":"FileTypeEnum"},"FileUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail"},"tags":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tags"},"usage":{"anyOf":[{"$ref":"#/components/schemas/FileUsageWrite"},{"type":"null"}],"description":"若提供则 upsert 一条 file_usages"}},"type":"object","title":"FileUpdate"},"FileUsageRead":{"properties":{"id":{"type":"integer","title":"Id"},"file_id":{"type":"string","title":"File Id"},"project_id":{"type":"string","title":"Project Id"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"},"usage_kind":{"type":"string","title":"Usage Kind"},"source_ref":{"type":"string","title":"Source Ref"}},"type":"object","required":["id","file_id","project_id","chapter_id","shot_id","usage_kind","source_ref"],"title":"FileUsageRead"},"FileUsageWrite":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID"},"usage_kind":{"type":"string","title":"Usage Kind","description":"用途:shot_frame / generated_video / character_image / asset_image / upload / api 等"},"source_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Ref","description":"幂等键(可选)"}},"type":"object","required":["project_id","usage_kind"],"title":"FileUsageWrite","description":"写入 file_usages 的关联信息(与 FileItem 一并提交)。"},"FrameGuidanceDecisionRead":{"properties":{"text":{"type":"string","title":"Text","description":"guidance 原文"},"category":{"type":"string","title":"Category","description":"guidance 分类,如 summary / continuity / composition / screen"},"reason_tag":{"type":"string","title":"Reason Tag","description":"简短原因标签,如 首帧保空间 / 关键帧保轴线","default":""},"reason":{"type":"string","title":"Reason","description":"该 guidance 被保留或压缩的原因说明"}},"type":"object","required":["text","category","reason"],"title":"FrameGuidanceDecisionRead","description":"分镜帧 guidance 的保留/压缩决策结果。"},"GenerationTaskLinkCreate":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"生成任务 ID"},"resource_type":{"type":"string","title":"Resource Type","description":"生成资源类型(如 image/video/text/task_link)"},"relation_type":{"type":"string","title":"Relation Type","description":"业务类型(如 prop/costume/scene 等)"},"relation_entity_id":{"type":"string","title":"Relation Entity Id","description":"关联业务实体 ID"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联产物文件 ID(files.id;适用于图片/音频/视频)"},"status":{"type":"string","title":"Status","description":"关联状态:accepted=已采用、todo=待操作、rejected=未采用;默认 todo","default":"todo"}},"type":"object","required":["task_id","resource_type","relation_type","relation_entity_id"],"title":"GenerationTaskLinkCreate","description":"创建生成任务关联请求体。"},"GenerationTaskLinkRead":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"生成任务 ID"},"resource_type":{"type":"string","title":"Resource Type","description":"生成资源类型(如 image/video/text/task_link)"},"relation_type":{"type":"string","title":"Relation Type","description":"业务类型(如 prop/costume/scene 等)"},"relation_entity_id":{"type":"string","title":"Relation Entity Id","description":"关联业务实体 ID"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联产物文件 ID(files.id;适用于图片/音频/视频)"},"status":{"type":"string","title":"Status","description":"关联状态:accepted=已采用、todo=待操作、rejected=未采用"},"id":{"type":"integer","title":"Id","description":"关联行 ID"}},"type":"object","required":["task_id","resource_type","relation_type","relation_entity_id","status","id"],"title":"GenerationTaskLinkRead","description":"生成任务关联返回体。"},"GenerationTaskLinkUpdate":{"properties":{"resource_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resource Type","description":"生成资源类型(如 image/video/text/task_link)"},"relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Type","description":"业务类型(如 prop/costume/scene 等)"},"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"关联业务实体 ID"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联产物文件 ID(files.id;适用于图片/音频/视频)"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status","description":"关联状态:accepted=已采用、todo=待操作、rejected=未采用"}},"type":"object","title":"GenerationTaskLinkUpdate","description":"更新生成任务关联请求体(不包含 is_adopted,采用状态由专用接口正向变更)。"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"ImageGenerationOptionsRead":{"properties":{"provider":{"type":"string","title":"Provider","description":"供应商稳定键"},"model_id":{"type":"string","title":"Model Id","description":"默认图片模型 ID"},"model_name":{"type":"string","title":"Model Name","description":"默认图片模型名称"},"supported_ratios":{"items":{"type":"string"},"type":"array","title":"Supported Ratios","description":"当前模型支持的目标比例"},"default_resolution_profile":{"type":"string","title":"Default Resolution Profile","description":"当前模型默认分辨率档位"},"ratio_size_profiles":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object","title":"Ratio Size Profiles","description":"按比例和分辨率档位映射得到的像素尺寸"}},"type":"object","required":["provider","model_id","model_name","default_resolution_profile"],"title":"ImageGenerationOptionsRead","description":"当前默认图片模型对应的关键帧规格选项。"},"LogLevel":{"type":"string","enum":["debug","info","warn","error"],"title":"LogLevel","description":"全局日志级别。"},"ModelCategoryKey":{"type":"string","enum":["text","image","video"],"title":"ModelCategoryKey","description":"模型类别:文本/图片/视频。"},"ModelCreate":{"properties":{"name":{"type":"string","title":"Name","description":"模型名称"},"category":{"$ref":"#/components/schemas/ModelCategoryKey","description":"模型类别:text/image/video"},"provider_id":{"type":"string","title":"Provider Id","description":"所属供应商 ID"},"params":{"additionalProperties":true,"type":"object","title":"Params","description":"模型参数(JSON)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"模型 ID"}},"type":"object","required":["name","category","provider_id","id"],"title":"ModelCreate","description":"创建模型请求体。"},"ModelRead":{"properties":{"name":{"type":"string","title":"Name","description":"模型名称"},"category":{"$ref":"#/components/schemas/ModelCategoryKey","description":"模型类别:text/image/video"},"provider_id":{"type":"string","title":"Provider Id","description":"所属供应商 ID"},"params":{"additionalProperties":true,"type":"object","title":"Params","description":"模型参数(JSON)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"模型 ID"}},"type":"object","required":["name","category","provider_id","id"],"title":"ModelRead","description":"对外返回的模型信息。"},"ModelSettingsRead":{"properties":{"default_text_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Text Model Id","description":"默认文本模型 ID"},"default_image_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Image Model Id","description":"默认图片模型 ID"},"default_video_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Model Id","description":"默认视频模型 ID"},"api_timeout":{"type":"integer","title":"Api Timeout","description":"API 超时(秒)","default":30},"log_level":{"$ref":"#/components/schemas/LogLevel","description":"日志级别","default":"info"},"id":{"type":"integer","title":"Id","description":"设置行 ID(通常为 1)"}},"type":"object","required":["id"],"title":"ModelSettingsRead","description":"对外返回的模型全局设置。"},"ModelSettingsUpdate":{"properties":{"default_text_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Text Model Id","description":"默认文本模型 ID"},"default_image_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Image Model Id","description":"默认图片模型 ID"},"default_video_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Model Id","description":"默认视频模型 ID"},"api_timeout":{"type":"integer","title":"Api Timeout","description":"API 超时(秒)","default":30},"log_level":{"$ref":"#/components/schemas/LogLevel","description":"日志级别","default":"info"}},"type":"object","title":"ModelSettingsUpdate","description":"更新或保存模型全局设置请求体。"},"ModelUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"模型名称"},"category":{"anyOf":[{"$ref":"#/components/schemas/ModelCategoryKey"},{"type":"null"}],"description":"模型类别"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id","description":"所属供应商 ID"},"params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Params","description":"模型参数(JSON)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"说明"}},"type":"object","title":"ModelUpdate","description":"更新模型请求体(全部可选)。"},"PaginatedData_Any_":{"properties":{"items":{"items":{},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[Any]"},"PaginatedData_ChapterRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ChapterRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ChapterRead]"},"PaginatedData_FileRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/FileRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[FileRead]"},"PaginatedData_GenerationTaskLinkRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/GenerationTaskLinkRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[GenerationTaskLinkRead]"},"PaginatedData_ModelRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ModelRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ModelRead]"},"PaginatedData_ProjectRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ProjectRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ProjectRead]"},"PaginatedData_PromptTemplateRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/PromptTemplateRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[PromptTemplateRead]"},"PaginatedData_ProviderRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ProviderRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ProviderRead]"},"PaginatedData_ShotDetailRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotDetailRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotDetailRead]"},"PaginatedData_ShotDialogLineRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotDialogLineRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotDialogLineRead]"},"PaginatedData_ShotFrameImageRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotFrameImageRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotFrameImageRead]"},"PaginatedData_ShotLinkedAssetItem_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotLinkedAssetItem"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotLinkedAssetItem]"},"PaginatedData_ShotRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotRead]"},"PaginatedData_TaskListItemRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/TaskListItemRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[TaskListItemRead]"},"PaginatedData_dict_str__Any__":{"properties":{"items":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[dict[str, Any]]"},"Pagination":{"properties":{"page":{"type":"integer","title":"Page","description":"当前页,从 1 开始"},"page_size":{"type":"integer","title":"Page Size","description":"每页条数"},"total":{"type":"integer","title":"Total","description":"总条数"},"max_page":{"type":"integer","title":"Max Page","description":"最大页码"}},"type":"object","required":["page","page_size","total","max_page"],"title":"Pagination","description":"分页信息。"},"ProjectActorLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"actor_id":{"type":"string","title":"Actor Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"演员缩略图下载地址","default":""}},"type":"object","required":["id","project_id","actor_id"],"title":"ProjectActorLinkRead"},"ProjectAssetLinkCreate":{"properties":{"project_id":{"type":"string","title":"Project Id"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"},"asset_id":{"type":"string","title":"Asset Id"}},"type":"object","required":["project_id","asset_id"],"title":"ProjectAssetLinkCreate"},"ProjectCostumeLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"costume_id":{"type":"string","title":"Costume Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"服装缩略图下载地址","default":""}},"type":"object","required":["id","project_id","costume_id"],"title":"ProjectCostumeLinkRead"},"ProjectCreate":{"properties":{"name":{"type":"string","title":"Name","description":"项目名称"},"description":{"type":"string","title":"Description","description":"项目简介","default":""},"style":{"$ref":"#/components/schemas/ProjectStyle","description":"题材/风格","examples":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"]},"visual_style":{"$ref":"#/components/schemas/ProjectVisualStyle","description":"画面表现形式","default":"现实"},"seed":{"type":"integer","title":"Seed","description":"随机种子","default":0},"unify_style":{"type":"boolean","title":"Unify Style","description":"是否统一风格","default":true},"progress":{"type":"integer","title":"Progress","description":"进度百分比(0-100)","default":0},"default_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Ratio","description":"项目级默认视频比例;分镜未覆盖时生效"},"stats":{"additionalProperties":true,"type":"object","title":"Stats","description":"聚合统计(JSON)"},"id":{"type":"string","title":"Id","description":"项目 ID"}},"type":"object","required":["name","style","id"],"title":"ProjectCreate"},"ProjectPropLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"prop_id":{"type":"string","title":"Prop Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"道具缩略图下载地址","default":""}},"type":"object","required":["id","project_id","prop_id"],"title":"ProjectPropLinkRead"},"ProjectRead":{"properties":{"name":{"type":"string","title":"Name","description":"项目名称"},"description":{"type":"string","title":"Description","description":"项目简介","default":""},"style":{"$ref":"#/components/schemas/ProjectStyle","description":"题材/风格","examples":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"]},"visual_style":{"$ref":"#/components/schemas/ProjectVisualStyle","description":"画面表现形式","default":"现实"},"seed":{"type":"integer","title":"Seed","description":"随机种子","default":0},"unify_style":{"type":"boolean","title":"Unify Style","description":"是否统一风格","default":true},"progress":{"type":"integer","title":"Progress","description":"进度百分比(0-100)","default":0},"default_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Ratio","description":"项目级默认视频比例;分镜未覆盖时生效"},"stats":{"additionalProperties":true,"type":"object","title":"Stats","description":"聚合统计(JSON)"},"id":{"type":"string","title":"Id"}},"type":"object","required":["name","style","id"],"title":"ProjectRead"},"ProjectSceneLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"scene_id":{"type":"string","title":"Scene Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"场景缩略图下载地址","default":""}},"type":"object","required":["id","project_id","scene_id"],"title":"ProjectSceneLinkRead"},"ProjectStyle":{"type":"string","enum":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"],"title":"ProjectStyle","description":"项目题材/风格维度(不用于区分真人/动漫)。"},"ProjectStyleOptionsRead":{"properties":{"visual_styles":{"items":{"$ref":"#/components/schemas/StyleOption"},"type":"array","title":"Visual Styles","description":"视觉风格可选项"},"styles_by_visual_style":{"additionalProperties":{"items":{"$ref":"#/components/schemas/StyleOption"},"type":"array"},"type":"object","title":"Styles By Visual Style","description":"按视觉风格分组的视频风格选项"},"default_style_by_visual_style":{"additionalProperties":{"type":"string"},"type":"object","title":"Default Style By Visual Style","description":"各视觉风格默认视频风格"}},"type":"object","title":"ProjectStyleOptionsRead","description":"项目风格候选项。"},"ProjectUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"style":{"anyOf":[{"$ref":"#/components/schemas/ProjectStyle"},{"type":"null"}],"description":"题材/风格","examples":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"]},"visual_style":{"anyOf":[{"$ref":"#/components/schemas/ProjectVisualStyle"},{"type":"null"}]},"seed":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Seed"},"unify_style":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Unify Style"},"progress":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Progress"},"default_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Ratio"},"stats":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Stats"}},"type":"object","title":"ProjectUpdate"},"ProjectVisualStyle":{"type":"string","enum":["现实","动漫"],"title":"ProjectVisualStyle","description":"画面表现形式维度:用于区分现实/动漫等。"},"PromptCategory":{"type":"string","enum":["frame_head_image","frame_tail_image","frame_key_image","frame_head_prompt","frame_tail_prompt","frame_key_prompt","video_prompt","storyboard_prompt","bgm","sfx","character_image_front","character_image_other","actor_image_front","actor_image_other","prop_image_front","prop_image_other","scene_image_front","scene_image_other","costume_image_front","costume_image_other","combined"],"title":"PromptCategory","description":"提示词模板类别。"},"PromptCategoryOptionRead":{"properties":{"value":{"$ref":"#/components/schemas/PromptCategory","description":"类别枚举值"},"label":{"type":"string","title":"Label","description":"中文名称"},"description":{"type":"string","title":"Description","description":"类别简介","default":""}},"type":"object","required":["value","label"],"title":"PromptCategoryOptionRead","description":"提示词类别选项(枚举值 + 中文标签 + 简介)。"},"PromptTemplateCreate":{"properties":{"category":{"$ref":"#/components/schemas/PromptCategory","description":"模板类别"},"name":{"type":"string","title":"Name","description":"模板名称"},"content":{"type":"string","title":"Content","description":"模板内容"},"preview":{"type":"string","title":"Preview","description":"预览文案","default":""},"variables":{"items":{"type":"string"},"type":"array","title":"Variables","description":"变量名列表"},"is_default":{"type":"boolean","title":"Is Default","description":"是否为默认提示词","default":false}},"type":"object","required":["category","name","content"],"title":"PromptTemplateCreate","description":"创建提示词模板。id 由后端自动生成;is_system 不可由客户端设置。"},"PromptTemplateRead":{"properties":{"id":{"type":"string","title":"Id","description":"模板 ID"},"category":{"$ref":"#/components/schemas/PromptCategory","description":"模板类别"},"name":{"type":"string","title":"Name","description":"模板名称"},"preview":{"type":"string","title":"Preview","description":"预览文案"},"content":{"type":"string","title":"Content","description":"模板内容"},"variables":{"items":{"type":"string"},"type":"array","title":"Variables","description":"变量名列表"},"is_default":{"type":"boolean","title":"Is Default","description":"是否为默认提示词"},"is_system":{"type":"boolean","title":"Is System","description":"是否为系统预置"},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"创建时间"},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"最后更新时间"}},"type":"object","required":["id","category","name","preview","content","variables","is_default","is_system","created_at","updated_at"],"title":"PromptTemplateRead","description":"读取提示词模板(含全部字段)。"},"PromptTemplateUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"模板名称"},"content":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content","description":"模板内容"},"preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview","description":"预览文案"},"variables":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Variables","description":"变量名列表(整体替换)"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default","description":"是否为默认提示词"}},"type":"object","title":"PromptTemplateUpdate","description":"局部更新提示词模板。不含 id / is_system。"},"PropInfoAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"prop_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prop Context","description":"原文道具上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"prop_description":{"type":"string","minLength":1,"title":"Prop Description","description":"原文道具描述"}},"type":"object","required":["prop_description"],"title":"PropInfoAnalysisRequest","description":"道具信息缺失分析请求。"},"PropInfoAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"PropInfoAnalysisResult","description":"根据原文道具描述,分析缺少的信息,并给出优化后的可生成道具描述。"},"ProviderCreate":{"properties":{"name":{"type":"string","title":"Name","description":"供应商名称"},"base_url":{"type":"string","title":"Base Url","description":"文本/通用 API Base URL"},"image_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Base Url","description":"图片能力 API Base URL(可选覆盖)"},"video_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Base Url","description":"视频能力 API Base URL(可选覆盖)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"status":{"$ref":"#/components/schemas/ProviderStatus","description":"状态:active/testing/disabled","default":"testing"},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"供应商 ID"},"api_key":{"type":"string","title":"Api Key","description":"API Key(敏感,不在响应中回显)","default":""},"api_secret":{"type":"string","title":"Api Secret","description":"API Secret(敏感,不在响应中回显)","default":""}},"type":"object","required":["name","base_url","id"],"title":"ProviderCreate","description":"创建供应商时的请求体,允许填写敏感字段。"},"ProviderRead":{"properties":{"name":{"type":"string","title":"Name","description":"供应商名称"},"base_url":{"type":"string","title":"Base Url","description":"文本/通用 API Base URL"},"image_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Base Url","description":"图片能力 API Base URL(可选覆盖)"},"video_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Base Url","description":"视频能力 API Base URL(可选覆盖)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"status":{"$ref":"#/components/schemas/ProviderStatus","description":"状态:active/testing/disabled","default":"testing"},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"供应商 ID"}},"type":"object","required":["name","base_url","id"],"title":"ProviderRead","description":"对外返回的供应商信息(不包含 api_key/api_secret)。"},"ProviderStatus":{"type":"string","enum":["active","testing","disabled"],"title":"ProviderStatus","description":"供应商启用状态。"},"ProviderSupportedRead":{"properties":{"key":{"type":"string","title":"Key","description":"供应商稳定键"},"display_name":{"type":"string","title":"Display Name","description":"供应商展示名"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases","description":"可识别别名"},"supported_categories":{"items":{"$ref":"#/components/schemas/ModelCategoryKey"},"type":"array","title":"Supported Categories","description":"支持的模型类别"},"default_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Base Url","description":"默认 API Base URL"},"requires_api_key":{"type":"boolean","title":"Requires Api Key","description":"是否要求 api_key","default":true},"requires_api_secret":{"type":"boolean","title":"Requires Api Secret","description":"是否要求 api_secret","default":false},"is_experimental":{"type":"boolean","title":"Is Experimental","description":"是否实验性供应商","default":false}},"type":"object","required":["key","display_name"],"title":"ProviderSupportedRead","description":"系统支持的供应商能力清单。"},"ProviderUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"供应商名称"},"base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Base Url","description":"文本/通用 API Base URL"},"image_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Base Url","description":"图片能力 API Base URL(可选覆盖)"},"video_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Base Url","description":"视频能力 API Base URL(可选覆盖)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"说明"},"status":{"anyOf":[{"$ref":"#/components/schemas/ProviderStatus"},{"type":"null"}],"description":"状态:active/testing/disabled"},"api_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Key","description":"API Key(敏感,不在响应中回显)"},"api_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Secret","description":"API Secret(敏感,不在响应中回显)"}},"type":"object","title":"ProviderUpdate","description":"更新供应商时的可选字段。"},"RenderedPromptResponse":{"properties":{"prompt":{"type":"string","title":"Prompt","description":"渲染后的提示词(已套用模板与变量替换)"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"参考图 file_id 列表(自动选择;顺序有效)"}},"type":"object","required":["prompt"],"title":"RenderedPromptResponse"},"RenderedShotFramePromptRead":{"properties":{"base_prompt":{"type":"string","title":"Base Prompt","description":"原始基础提示词(不含图片映射说明)"},"rendered_prompt":{"type":"string","title":"Rendered Prompt","description":"最终提交给模型的提示词(含图片映射说明)"},"selected_guidance":{"items":{"type":"string"},"type":"array","title":"Selected Guidance","description":"最终 prompt 实际保留的 guidance 列表"},"dropped_guidance":{"items":{"type":"string"},"type":"array","title":"Dropped Guidance","description":"本次渲染中被压缩掉的 guidance 列表"},"selected_guidance_details":{"items":{"$ref":"#/components/schemas/FrameGuidanceDecisionRead"},"type":"array","title":"Selected Guidance Details","description":"最终保留 guidance 的决策详情"},"dropped_guidance_details":{"items":{"$ref":"#/components/schemas/FrameGuidanceDecisionRead"},"type":"array","title":"Dropped Guidance Details","description":"被压缩 guidance 的决策详情"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"最终参考图 file_id 列表,顺序与 mappings 一致"},"mappings":{"items":{"$ref":"#/components/schemas/ShotFramePromptMappingRead"},"type":"array","title":"Mappings","description":"图片与实体名称的映射关系,顺序与 images 完全一致"}},"type":"object","required":["base_prompt","rendered_prompt"],"title":"RenderedShotFramePromptRead","description":"关键帧最终生成提示词渲染结果。"},"SceneInfoAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"scene_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Context","description":"原文场景上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"scene_description":{"type":"string","minLength":1,"title":"Scene Description","description":"原文场景描述"}},"type":"object","required":["scene_description"],"title":"SceneInfoAnalysisRequest","description":"场景信息缺失分析请求。"},"SceneInfoAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"SceneInfoAnalysisResult","description":"根据原文场景描述,分析缺少的信息,并给出优化后的可生成场景描述。"},"ScriptConsistencyCheckRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"完整剧本文本"}},"type":"object","required":["script_text"],"title":"ScriptConsistencyCheckRequest","description":"一致性检查请求(角色混淆)。"},"ScriptConsistencyCheckResult":{"properties":{"issues":{"items":{"$ref":"#/components/schemas/ScriptConsistencyIssue"},"type":"array","title":"Issues","description":"问题列表"},"has_issues":{"type":"boolean","title":"Has Issues","description":"是否发现问题"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary","description":"总结(可选)"}},"additionalProperties":false,"type":"object","required":["has_issues"],"title":"ScriptConsistencyCheckResult","description":"基于原文的一致性检查结果(聚焦角色混淆)。"},"ScriptConsistencyIssue":{"properties":{"issue_type":{"type":"string","const":"character_confusion","title":"Issue Type","description":"固定为角色混淆类问题","default":"character_confusion"},"character_candidates":{"items":{"type":"string"},"type":"array","title":"Character Candidates","description":"涉及的角色候选(名字/称呼/ID 皆可,优先用原文称呼)"},"description":{"type":"string","title":"Description","description":"问题描述(为什么会混淆)"},"suggestion":{"type":"string","title":"Suggestion","description":"修改建议(如何改写以消除混淆)"},"affected_lines":{"anyOf":[{"additionalProperties":{"type":"integer"},"type":"object"},{"type":"null"}],"title":"Affected Lines","description":"受影响的行号范围,形如 {start_line: x, end_line: y}"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["description","suggestion"],"title":"ScriptConsistencyIssue","description":"角色混淆类一致性问题:同一角色在不同镜头被赋予不同身份/行为主体导致混淆。"},"ScriptDividerRequest":{"properties":{"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"完整剧本文本"},"write_to_db":{"type":"boolean","title":"Write To Db","description":"是否将分镜写入数据库(AI Studio shots 表)","default":false},"chapter_id":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(write_to_db=true 时必填)"}},"type":"object","required":["script_text"],"title":"ScriptDividerRequest","description":"剧本分镜请求。"},"ScriptDivisionResult":{"properties":{"shots":{"items":{"$ref":"#/components/schemas/ShotDivision"},"type":"array","title":"Shots","description":"分镜列表"},"total_shots":{"type":"integer","minimum":0.0,"title":"Total Shots","description":"总镜头数"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"拆分说明或建议(可选)"}},"additionalProperties":false,"type":"object","required":["total_shots"],"title":"ScriptDivisionResult","description":"剧本分镜结果:镜头列表(每镜起止行号+预览文本)。"},"ScriptExtractRequest":{"properties":{"project_id":{"type":"string","minLength":1,"title":"Project Id","description":"项目 ID"},"chapter_id":{"type":"string","minLength":1,"title":"Chapter Id","description":"章节 ID"},"script_division":{"additionalProperties":true,"type":"object","title":"Script Division","description":"分镜结果(ScriptDivisionResult 序列化)"},"consistency":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Consistency","description":"一致性检查结果(可选;ScriptConsistencyCheckResult 序列化)"},"refresh_cache":{"type":"boolean","title":"Refresh Cache","description":"是否跳过后端缓存并强制重新提取","default":false}},"type":"object","required":["project_id","chapter_id","script_division"],"title":"ScriptExtractRequest","description":"项目级信息提取请求(最终输出)。"},"ScriptOptimizationResult":{"properties":{"optimized_script_text":{"type":"string","title":"Optimized Script Text","description":"优化后的剧本文本"},"change_summary":{"type":"string","title":"Change Summary","description":"改动摘要(只围绕 issues)"}},"additionalProperties":false,"type":"object","required":["optimized_script_text","change_summary"],"title":"ScriptOptimizationResult","description":"剧本优化输出:仅在发现角色混淆问题时使用。"},"ScriptOptimizeRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"原文剧本文本"},"consistency":{"additionalProperties":true,"type":"object","title":"Consistency","description":"一致性检查输出(ScriptConsistencyCheckResult 序列化)"}},"type":"object","required":["script_text","consistency"],"title":"ScriptOptimizeRequest","description":"剧本优化请求(基于一致性检查结果)。"},"ScriptSimplificationResult":{"properties":{"simplified_script_text":{"type":"string","title":"Simplified Script Text","description":"精简后的剧本文本"},"simplification_summary":{"type":"string","title":"Simplification Summary","description":"精简策略摘要(说明删改原则)"}},"additionalProperties":false,"type":"object","required":["simplified_script_text","simplification_summary"],"title":"ScriptSimplificationResult","description":"剧本精简输出:在保留剧情主体与连续性的前提下压缩篇幅。"},"ScriptSimplifyRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"原文剧本文本"}},"type":"object","required":["script_text"],"title":"ScriptSimplifyRequest","description":"智能精简剧本请求。"},"ShotAssetOverviewItem":{"properties":{"key":{"type":"string","title":"Key","description":"合并键:type:name"},"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"实体类型:character/prop/scene/costume"},"name":{"type":"string","title":"Name","description":"资产名称"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"候选描述(来自 extraction payload)"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"缩略图或参考图文件 ID"},"source":{"type":"string","enum":["linked","candidate","both"],"title":"Source","description":"来源:linked/candidate/both"},"candidate_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Candidate Id","description":"候选项 ID"},"candidate_status":{"anyOf":[{"$ref":"#/components/schemas/ShotCandidateStatus"},{"type":"null"}],"description":"候选确认状态"},"linked_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Linked Entity Id","description":"当前已关联实体 ID"},"linked_image_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Linked Image Id","description":"当前已关联实体的 image 行 ID"},"is_linked":{"type":"boolean","title":"Is Linked","description":"当前是否已关联到镜头"}},"type":"object","required":["key","type","name","source","is_linked"],"title":"ShotAssetOverviewItem","description":"分镜资产总览项:统一返回已关联资产与提取候选的合并视图。"},"ShotAssetsOverviewRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"skip_extraction":{"type":"boolean","title":"Skip Extraction","description":"是否明确跳过提取"},"status":{"$ref":"#/components/schemas/ShotStatus","description":"镜头流程状态"},"summary":{"$ref":"#/components/schemas/ShotAssetsOverviewSummary","description":"总览统计"},"items":{"items":{"$ref":"#/components/schemas/ShotAssetOverviewItem"},"type":"array","title":"Items","description":"资产总览项"}},"type":"object","required":["shot_id","skip_extraction","status","summary"],"title":"ShotAssetsOverviewRead"},"ShotAssetsOverviewSummary":{"properties":{"linked_count":{"type":"integer","title":"Linked Count","description":"已关联项数量"},"pending_count":{"type":"integer","title":"Pending Count","description":"待确认候选数量"},"ignored_count":{"type":"integer","title":"Ignored Count","description":"已忽略候选数量"},"total_count":{"type":"integer","title":"Total Count","description":"总项数(含 ignored)"}},"type":"object","required":["linked_count","pending_count","ignored_count","total_count"],"title":"ShotAssetsOverviewSummary"},"ShotCandidateStatus":{"type":"string","enum":["pending","linked","ignored"],"title":"ShotCandidateStatus","description":"镜头提取候选确认状态。"},"ShotCandidateType":{"type":"string","enum":["character","scene","prop","costume"],"title":"ShotCandidateType","description":"镜头提取候选类型。"},"ShotCharacterLinkCreate":{"properties":{"shot_id":{"type":"string","title":"Shot Id"},"character_id":{"type":"string","title":"Character Id"},"index":{"type":"integer","title":"Index","default":0},"note":{"type":"string","title":"Note","default":""}},"type":"object","required":["shot_id","character_id"],"title":"ShotCharacterLinkCreate"},"ShotCharacterLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"character_id":{"type":"string","title":"Character Id","description":"角色 ID"},"index":{"type":"integer","title":"Index","description":"镜头内角色排序","default":0},"note":{"type":"string","title":"Note","description":"备注","default":""}},"type":"object","required":["id","shot_id","character_id"],"title":"ShotCharacterLinkRead"},"ShotCreate":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID"},"chapter_id":{"type":"string","title":"Chapter Id","description":"所属章节 ID"},"index":{"type":"integer","title":"Index","description":"镜头序号(章节内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"status":{"$ref":"#/components/schemas/ShotStatus","description":"镜头状态","default":"pending"},"skip_extraction":{"type":"boolean","title":"Skip Extraction","description":"是否明确跳过信息提取","default":false},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"generated_video_file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generated Video File Id","description":"已生成视频关联的文件 ID(files.id,type=video)"}},"type":"object","required":["id","chapter_id","index","title"],"title":"ShotCreate"},"ShotDetailCreate":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID(与 shots.id 共享主键)"},"camera_shot":{"$ref":"#/components/schemas/CameraShotType","description":"景别"},"angle":{"$ref":"#/components/schemas/CameraAngle","description":"机位角度"},"movement":{"$ref":"#/components/schemas/CameraMovement","description":"运镜方式"},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id","description":"关联场景 ID(可空)"},"duration":{"type":"integer","title":"Duration","description":"时长(秒)","default":0},"override_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Override Video Ratio","description":"分镜级视频比例覆盖;为空表示继承项目默认"},"mood_tags":{"items":{"type":"string"},"type":"array","title":"Mood Tags","description":"情绪标签"},"atmosphere":{"type":"string","title":"Atmosphere","description":"氛围描述","default":""},"follow_atmosphere":{"type":"boolean","title":"Follow Atmosphere","description":"是否沿用氛围","default":true},"has_bgm":{"type":"boolean","title":"Has Bgm","description":"是否包含 BGM","default":false},"vfx_type":{"$ref":"#/components/schemas/VFXType","description":"视效类型","default":"NONE"},"vfx_note":{"type":"string","title":"Vfx Note","description":"视效说明","default":""},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"动作拍点(按时间顺序排列)"},"first_frame_prompt":{"type":"string","title":"First Frame Prompt","description":"镜头分镜首帧提示词","default":""},"last_frame_prompt":{"type":"string","title":"Last Frame Prompt","description":"镜头分镜尾帧提示词","default":""},"key_frame_prompt":{"type":"string","title":"Key Frame Prompt","description":"镜头分镜关键帧提示词","default":""}},"type":"object","required":["id","camera_shot","angle","movement"],"title":"ShotDetailCreate"},"ShotDetailRead":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID(与 shots.id 共享主键)"},"camera_shot":{"$ref":"#/components/schemas/CameraShotType","description":"景别"},"angle":{"$ref":"#/components/schemas/CameraAngle","description":"机位角度"},"movement":{"$ref":"#/components/schemas/CameraMovement","description":"运镜方式"},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id","description":"关联场景 ID(可空)"},"duration":{"type":"integer","title":"Duration","description":"时长(秒)","default":0},"override_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Override Video Ratio","description":"分镜级视频比例覆盖;为空表示继承项目默认"},"mood_tags":{"items":{"type":"string"},"type":"array","title":"Mood Tags","description":"情绪标签"},"atmosphere":{"type":"string","title":"Atmosphere","description":"氛围描述","default":""},"follow_atmosphere":{"type":"boolean","title":"Follow Atmosphere","description":"是否沿用氛围","default":true},"has_bgm":{"type":"boolean","title":"Has Bgm","description":"是否包含 BGM","default":false},"vfx_type":{"$ref":"#/components/schemas/VFXType","description":"视效类型","default":"NONE"},"vfx_note":{"type":"string","title":"Vfx Note","description":"视效说明","default":""},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"动作拍点(按时间顺序排列)"},"first_frame_prompt":{"type":"string","title":"First Frame Prompt","description":"镜头分镜首帧提示词","default":""},"last_frame_prompt":{"type":"string","title":"Last Frame Prompt","description":"镜头分镜尾帧提示词","default":""},"key_frame_prompt":{"type":"string","title":"Key Frame Prompt","description":"镜头分镜关键帧提示词","default":""}},"type":"object","required":["id","camera_shot","angle","movement"],"title":"ShotDetailRead"},"ShotDetailUpdate":{"properties":{"camera_shot":{"anyOf":[{"$ref":"#/components/schemas/CameraShotType"},{"type":"null"}]},"angle":{"anyOf":[{"$ref":"#/components/schemas/CameraAngle"},{"type":"null"}]},"movement":{"anyOf":[{"$ref":"#/components/schemas/CameraMovement"},{"type":"null"}]},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id"},"duration":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration"},"override_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Override Video Ratio"},"mood_tags":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Mood Tags"},"atmosphere":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Atmosphere"},"follow_atmosphere":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Follow Atmosphere"},"has_bgm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Bgm"},"vfx_type":{"anyOf":[{"$ref":"#/components/schemas/VFXType"},{"type":"null"}]},"vfx_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Vfx Note"},"action_beats":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Action Beats"},"first_frame_prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"First Frame Prompt"},"last_frame_prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Frame Prompt"},"key_frame_prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key Frame Prompt"}},"type":"object","title":"ShotDetailUpdate"},"ShotDialogLineCreate":{"properties":{"shot_detail_id":{"type":"string","title":"Shot Detail Id"},"index":{"type":"integer","title":"Index","default":0},"text":{"type":"string","title":"Text"},"line_mode":{"$ref":"#/components/schemas/DialogueLineMode","default":"DIALOGUE"},"speaker_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Id"},"target_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Character Id"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name"}},"type":"object","required":["shot_detail_id","text"],"title":"ShotDialogLineCreate"},"ShotDialogLineRead":{"properties":{"id":{"type":"integer","title":"Id","description":"对话行 ID"},"shot_detail_id":{"type":"string","title":"Shot Detail Id","description":"所属镜头细节 ID"},"index":{"type":"integer","title":"Index","description":"行号(镜头内排序)","default":0},"text":{"type":"string","title":"Text","description":"台词内容"},"line_mode":{"$ref":"#/components/schemas/DialogueLineMode","description":"对白模式","default":"DIALOGUE"},"speaker_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Id","description":"说话角色 ID"},"target_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Character Id","description":"听者角色 ID"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"说话角色名称(用于回填关联;可空)"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"听者角色名称(用于回填关联;可空)"}},"type":"object","required":["id","shot_detail_id","text"],"title":"ShotDialogLineRead"},"ShotDialogLineUpdate":{"properties":{"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Text"},"line_mode":{"anyOf":[{"$ref":"#/components/schemas/DialogueLineMode"},{"type":"null"}]},"speaker_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Id"},"target_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Character Id"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name"}},"type":"object","title":"ShotDialogLineUpdate"},"ShotDialogueCandidateStatus":{"type":"string","enum":["pending","accepted","ignored"],"title":"ShotDialogueCandidateStatus","description":"镜头对白提取候选确认状态。"},"ShotDivision":{"properties":{"index":{"type":"integer","minimum":1.0,"title":"Index","description":"镜头序号(章节内唯一)"},"start_line":{"type":"integer","minimum":1.0,"title":"Start Line","description":"起始行号(1-based)"},"end_line":{"type":"integer","minimum":1.0,"title":"End Line","description":"结束行号(1-based)"},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"镜头对应的剧本摘录/文本"},"shot_name":{"type":"string","title":"Shot Name","description":"镜头名称(分镜名/镜头标题)","default":""},"time_of_day":{"anyOf":[{"type":"string","enum":["DAY","NIGHT","DAWN","DUSK","UNKNOWN","日","夜","黎明","黄昏","不明","未知"]},{"type":"null"}],"title":"Time Of Day","description":"时间(日/夜/未知等,可选)"}},"additionalProperties":false,"type":"object","required":["index","start_line","end_line","script_excerpt"],"title":"ShotDivision","description":"剧本分镜中的单镜信息:行号 + 预览文本(可选弱语义)。"},"ShotExtractedCandidateLinkRequest":{"properties":{"linked_entity_id":{"type":"string","title":"Linked Entity Id","description":"确认关联到的实体 ID"}},"type":"object","required":["linked_entity_id"],"title":"ShotExtractedCandidateLinkRequest"},"ShotExtractedCandidateRead":{"properties":{"id":{"type":"integer","title":"Id","description":"候选项 ID"},"shot_id":{"type":"string","title":"Shot Id","description":"所属镜头 ID"},"candidate_type":{"$ref":"#/components/schemas/ShotCandidateType","description":"候选类型"},"candidate_name":{"type":"string","title":"Candidate Name","description":"提取出的候选名称"},"candidate_status":{"$ref":"#/components/schemas/ShotCandidateStatus","description":"候选确认状态"},"linked_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Linked Entity Id","description":"已关联实体 ID"},"source":{"type":"string","title":"Source","description":"候选来源"},"payload":{"additionalProperties":true,"type":"object","title":"Payload","description":"候选附加信息"},"confirmed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Confirmed At","description":"确认时间"},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"创建时间"},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"更新时间"}},"type":"object","required":["id","shot_id","candidate_type","candidate_name","candidate_status","source","created_at","updated_at"],"title":"ShotExtractedCandidateRead"},"ShotExtractedDialogueCandidateAcceptRequest":{"properties":{"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index","description":"写入对白行时使用的排序;为空则使用候选排序"},"text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Text","description":"接受时可覆盖对白文本"},"line_mode":{"anyOf":[{"$ref":"#/components/schemas/DialogueLineMode"},{"type":"null"}],"description":"接受时可覆盖对白模式"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"接受时可覆盖说话角色名称"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"接受时可覆盖听者角色名称"}},"type":"object","title":"ShotExtractedDialogueCandidateAcceptRequest"},"ShotExtractedDialogueCandidateRead":{"properties":{"id":{"type":"integer","title":"Id","description":"对白候选项 ID"},"shot_id":{"type":"string","title":"Shot Id","description":"所属镜头 ID"},"index":{"type":"integer","title":"Index","description":"镜头内对白候选排序"},"text":{"type":"string","title":"Text","description":"提取出的对白文本"},"line_mode":{"$ref":"#/components/schemas/DialogueLineMode","description":"对白模式"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"说话角色名称"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"听者角色名称"},"candidate_status":{"$ref":"#/components/schemas/ShotDialogueCandidateStatus","description":"对白候选确认状态"},"linked_dialog_line_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Linked Dialog Line Id","description":"已接受后关联的对白行 ID"},"source":{"type":"string","title":"Source","description":"候选来源"},"payload":{"additionalProperties":true,"type":"object","title":"Payload","description":"候选附加信息"},"confirmed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Confirmed At","description":"确认时间"},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"创建时间"},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"更新时间"}},"type":"object","required":["id","shot_id","index","text","line_mode","candidate_status","source","created_at","updated_at"],"title":"ShotExtractedDialogueCandidateRead"},"ShotExtractionSummaryRead":{"properties":{"state":{"type":"string","enum":["not_extracted","extracted_empty","extracted_pending","extracted_resolved","skipped"],"title":"State","description":"镜头提取确认状态摘要"},"has_extracted":{"type":"boolean","title":"Has Extracted","description":"是否已执行过提取"},"last_extracted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Extracted At","description":"最近一次提取完成时间"},"asset_candidate_total":{"type":"integer","title":"Asset Candidate Total","description":"资产候选总数","default":0},"dialogue_candidate_total":{"type":"integer","title":"Dialogue Candidate Total","description":"对白候选总数","default":0},"pending_asset_count":{"type":"integer","title":"Pending Asset Count","description":"待确认资产候选数","default":0},"pending_dialogue_count":{"type":"integer","title":"Pending Dialogue Count","description":"待确认对白候选数","default":0}},"type":"object","required":["state","has_extracted"],"title":"ShotExtractionSummaryRead"},"ShotFrameImageCreate":{"properties":{"shot_detail_id":{"type":"string","title":"Shot Detail Id"},"frame_type":{"$ref":"#/components/schemas/ShotFrameType"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id"},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width"},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height"},"format":{"type":"string","title":"Format","default":"png"}},"type":"object","required":["shot_detail_id","frame_type"],"title":"ShotFrameImageCreate"},"ShotFrameImageRead":{"properties":{"id":{"type":"integer","title":"Id","description":"图片行 ID"},"shot_detail_id":{"type":"string","title":"Shot Detail Id","description":"所属镜头细节 ID"},"frame_type":{"$ref":"#/components/schemas/ShotFrameType","description":"帧类型:first/last/key"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联的 FileItem ID(可为空,允许先创建占位)"},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width","description":"宽(px)"},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height","description":"高(px)"},"format":{"type":"string","title":"Format","description":"格式","default":"png"}},"type":"object","required":["id","shot_detail_id","frame_type"],"title":"ShotFrameImageRead"},"ShotFrameImageTaskRequest":{"properties":{"model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Id","description":"可选模型 ID(models.id);不传则使用 ModelSettings.default_image_model_id;Provider 由模型关联反查"},"frame_type":{"$ref":"#/components/schemas/ShotFrameType","description":"first | last | key"},"prompt":{"type":"string","minLength":1,"title":"Prompt","description":"提示词(由前端传入,创建任务接口必填)。"},"images":{"items":{"$ref":"#/components/schemas/ShotLinkedAssetItem"},"type":"array","title":"Images","description":"参考资产条目列表(可多张,顺序有效)。后端会使用 item.file_id 作为参考图;无效条目会被跳过。"},"target_ratio":{"type":"string","enum":["16:9","4:3","1:1","3:4","9:16","21:9","3:2","2:3"],"title":"Target Ratio","description":"目标视频画幅比例;关键帧将按该画幅生成,以提升后续视频参考稳定性"},"resolution_profile":{"anyOf":[{"type":"string","enum":["standard","high"]},{"type":"null"}],"title":"Resolution Profile","description":"关键帧输出分辨率档位,默认 standard","default":"standard"}},"type":"object","required":["frame_type","prompt","target_ratio"],"title":"ShotFrameImageTaskRequest","description":"镜头分镜帧图片生成请求体:只根据 `shot_id + frame_type` 定位 ShotFrameImage。\n\n用于替代旧接口中通过 `image_id` 直接传入 ShotFrameImage.id 的方式。"},"ShotFrameImageUpdate":{"properties":{"frame_type":{"anyOf":[{"$ref":"#/components/schemas/ShotFrameType"},{"type":"null"}]},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id"},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width"},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height"},"format":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Format"}},"type":"object","title":"ShotFrameImageUpdate"},"ShotFramePromptMappingRead":{"properties":{"token":{"type":"string","title":"Token","description":"提示词中的图片占位 token,如 图1 / 图2"},"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"实体类型:character/prop/scene/costume"},"id":{"type":"string","title":"Id","description":"实体 ID(如 character_id/prop_id/scene_id/costume_id)"},"name":{"type":"string","title":"Name","description":"实体名称"},"file_id":{"type":"string","title":"File Id","description":"本次渲染与生成使用的文件 ID"}},"type":"object","required":["token","type","id","name","file_id"],"title":"ShotFramePromptMappingRead","description":"关键帧提示词渲染后的图片映射关系。"},"ShotFramePromptRenderRequest":{"properties":{"frame_type":{"$ref":"#/components/schemas/ShotFrameType","description":"first | last | key"},"prompt":{"type":"string","minLength":1,"title":"Prompt","description":"原始基础提示词。渲染接口要求显式传入,用于生成最终提示词。"},"images":{"items":{"$ref":"#/components/schemas/ShotLinkedAssetItem"},"type":"array","title":"Images","description":"参考资产条目列表(可多张,顺序有效)。后端会使用 item.file_id 作为参考图;无效条目会被跳过。"}},"type":"object","required":["frame_type","prompt"],"title":"ShotFramePromptRenderRequest","description":"镜头分镜帧提示词渲染请求体。"},"ShotFramePromptRequest":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"frame_type":{"type":"string","title":"Frame Type","description":"first | last | key"}},"type":"object","required":["shot_id","frame_type"],"title":"ShotFramePromptRequest","description":"镜头分镜帧提示词生成任务请求。"},"ShotFrameType":{"type":"string","enum":["first","last","key"],"title":"ShotFrameType","description":"镜头分镜帧类型:首帧/尾帧/关键帧。"},"ShotLinkedAssetItem":{"properties":{"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"实体类型:character/prop/scene/costume"},"id":{"type":"string","title":"Id","description":"实体 ID(如 character_id/prop_id/scene_id/costume_id)"},"image_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Image Id","description":"最佳缩略图对应的 image 行 ID(如 PropImage.id);无图则为 null"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"最佳缩略图对应的文件 ID(files.id);用于参考图输入;无图则为 null"},"name":{"type":"string","title":"Name","description":"实体名称"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图下载地址(/api/v1/studio/files/{file_id}/download)","default":""}},"type":"object","required":["type","id","name"],"title":"ShotLinkedAssetItem","description":"按分镜聚合返回的关联资产条目(角色/道具/场景/服装)。"},"ShotPreparationLinkEntityType":{"type":"string","enum":["character","scene","prop","costume"],"title":"ShotPreparationLinkEntityType"},"ShotPreparationLinkRequest":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"type":"string","title":"Chapter Id","description":"章节 ID"},"entity_type":{"$ref":"#/components/schemas/ShotPreparationLinkEntityType","description":"准备页关联的实体类型"},"linked_entity_id":{"type":"string","title":"Linked Entity Id","description":"要关联的实体 ID"}},"type":"object","required":["project_id","chapter_id","entity_type","linked_entity_id"],"title":"ShotPreparationLinkRequest"},"ShotPreparationMutationAction":{"type":"string","enum":["link_asset_candidate","ignore_asset_candidate","accept_dialogue_candidate","ignore_dialogue_candidate","skip_extraction","resume_extraction"],"title":"ShotPreparationMutationAction"},"ShotPreparationMutationResultRead":{"properties":{"action":{"$ref":"#/components/schemas/ShotPreparationMutationAction","description":"本次执行的准备页动作"},"state":{"$ref":"#/components/schemas/ShotPreparationStateRead","description":"动作完成后的最新准备页聚合状态"}},"type":"object","required":["action","state"],"title":"ShotPreparationMutationResultRead","description":"准备页命令执行后的统一响应。"},"ShotPreparationStateRead":{"properties":{"shot":{"$ref":"#/components/schemas/ShotRead","description":"当前镜头最新状态"},"assets_overview":{"$ref":"#/components/schemas/ShotAssetsOverviewRead","description":"资产确认区聚合状态"},"dialogue_candidates":{"items":{"$ref":"#/components/schemas/ShotExtractedDialogueCandidateRead"},"type":"array","title":"Dialogue Candidates","description":"当前待处理/已存在的对白候选"},"saved_dialogue_lines":{"items":{"$ref":"#/components/schemas/ShotDialogLineRead"},"type":"array","title":"Saved Dialogue Lines","description":"当前已保存的对白行"},"pending_confirm_count":{"type":"integer","title":"Pending Confirm Count","description":"当前仍待确认的总数量(资产 + 对白)"},"basic_info_ready":{"type":"boolean","title":"Basic Info Ready","description":"标题与剧本摘录是否已补齐"},"semantic_defaults_ready":{"type":"boolean","title":"Semantic Defaults Ready","description":"镜头语言默认值是否已确认"},"action_beats_ready":{"type":"boolean","title":"Action Beats Ready","description":"动作拍点是否已确认"},"action_beats_count":{"type":"integer","title":"Action Beats Count","description":"当前已确认动作拍点数量","default":0},"action_beat_phases":{"items":{"$ref":"#/components/schemas/ActionBeatPhaseRead"},"type":"array","title":"Action Beat Phases","description":"当前动作拍点的阶段推断结果"},"ready_for_generation":{"type":"boolean","title":"Ready For Generation","description":"当前镜头是否已完成准备,可进入后续生成"}},"type":"object","required":["shot","assets_overview","pending_confirm_count","basic_info_ready","semantic_defaults_ready","action_beats_ready","ready_for_generation"],"title":"ShotPreparationStateRead","description":"分镜准备页聚合状态。"},"ShotPromptAssetRef":{"properties":{"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"资产类型"},"name":{"type":"string","title":"Name","description":"资产名称"},"description":{"type":"string","title":"Description","description":"资产描述或提取候选描述","default":""},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"可作为参考图的文件 ID"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图"}},"type":"object","required":["type","name"],"title":"ShotPromptAssetRef","description":"用于提示词渲染的镜头资产引用。"},"ShotPromptCameraInfo":{"properties":{"camera_shot":{"type":"string","title":"Camera Shot","description":"景别","default":""},"angle":{"type":"string","title":"Angle","description":"机位角度","default":""},"movement":{"type":"string","title":"Movement","description":"运镜方式","default":""},"duration":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration","description":"镜头时长(秒)"}},"type":"object","title":"ShotPromptCameraInfo","description":"用于提示词渲染的镜头语言信息。"},"ShotRead":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID"},"chapter_id":{"type":"string","title":"Chapter Id","description":"所属章节 ID"},"index":{"type":"integer","title":"Index","description":"镜头序号(章节内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"status":{"$ref":"#/components/schemas/ShotStatus","description":"镜头状态","default":"pending"},"skip_extraction":{"type":"boolean","title":"Skip Extraction","description":"是否明确跳过信息提取","default":false},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"generated_video_file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generated Video File Id","description":"已生成视频关联的文件 ID(files.id,type=video)"},"last_extracted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Extracted At","description":"最近一次完成信息提取的时间"},"extraction":{"$ref":"#/components/schemas/ShotExtractionSummaryRead","description":"镜头提取状态摘要"}},"type":"object","required":["id","chapter_id","index","title","extraction"],"title":"ShotRead"},"ShotRuntimeSummaryRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"has_active_tasks":{"type":"boolean","title":"Has Active Tasks","description":"是否存在进行中的关联任务"},"has_active_video_tasks":{"type":"boolean","title":"Has Active Video Tasks","description":"是否存在进行中的视频任务"},"has_active_prompt_tasks":{"type":"boolean","title":"Has Active Prompt Tasks","description":"是否存在进行中的提示词任务"},"has_active_frame_tasks":{"type":"boolean","title":"Has Active Frame Tasks","description":"是否存在进行中的分镜帧图片任务"},"active_task_count":{"type":"integer","title":"Active Task Count","description":"进行中的唯一任务数"}},"type":"object","required":["shot_id","has_active_tasks","has_active_video_tasks","has_active_prompt_tasks","has_active_frame_tasks","active_task_count"],"title":"ShotRuntimeSummaryRead"},"ShotSemanticSuggestion":{"properties":{"camera_shot":{"anyOf":[{"$ref":"#/components/schemas/CameraShotType"},{"type":"null"}],"description":"建议景别"},"angle":{"anyOf":[{"$ref":"#/components/schemas/CameraAngle"},{"type":"null"}],"description":"建议机位"},"movement":{"anyOf":[{"$ref":"#/components/schemas/CameraMovement"},{"type":"null"}],"description":"建议运镜"},"duration":{"anyOf":[{"type":"integer","minimum":1.0},{"type":"null"}],"title":"Duration","description":"建议时长(秒)"},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"按时间顺序排列的动作拍点"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"不确定项说明"}},"additionalProperties":false,"type":"object","title":"ShotSemanticSuggestion","description":"镜头语义默认建议:用于准备阶段初始化镜头语言与动作拍点。"},"ShotSkipExtractionUpdate":{"properties":{"skip":{"type":"boolean","title":"Skip","description":"是否明确跳过信息提取"}},"type":"object","required":["skip"],"title":"ShotSkipExtractionUpdate"},"ShotStatus":{"type":"string","enum":["pending","generating","ready"],"title":"ShotStatus","description":"镜头生成状态(更多是“生产流程”而非剧情状态)。"},"ShotUpdate":{"properties":{"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail"},"status":{"anyOf":[{"$ref":"#/components/schemas/ShotStatus"},{"type":"null"}]},"skip_extraction":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Skip Extraction"},"script_excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script Excerpt"},"generated_video_file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generated Video File Id"}},"type":"object","title":"ShotUpdate"},"ShotVideoPromptPackRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"title":{"type":"string","title":"Title","description":"镜头标题","default":""},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"动作/场景要点"},"action_beat_phases":{"items":{"$ref":"#/components/schemas/ActionBeatPhaseRead"},"type":"array","title":"Action Beat Phases","description":"动作拍点的阶段推断结果"},"previous_shot_summary":{"type":"string","title":"Previous Shot Summary","description":"上一镜头摘要,用于提示词连续性约束","default":""},"next_shot_goal":{"type":"string","title":"Next Shot Goal","description":"下一镜头目标,用于提示词连续性约束","default":""},"continuity_guidance":{"type":"string","title":"Continuity Guidance","description":"当前镜头与相邻镜头的承接建议","default":""},"composition_anchor":{"type":"string","title":"Composition Anchor","description":"当前镜头的构图与空间锚点建议","default":""},"screen_direction_guidance":{"type":"string","title":"Screen Direction Guidance","description":"当前镜头的人物朝向、视线与左右轴线建议","default":""},"dialogue_summary":{"type":"string","title":"Dialogue Summary","description":"对白摘要","default":""},"characters":{"items":{"$ref":"#/components/schemas/ShotPromptAssetRef"},"type":"array","title":"Characters","description":"角色引用"},"scene":{"anyOf":[{"$ref":"#/components/schemas/ShotPromptAssetRef"},{"type":"null"}],"description":"场景引用"},"props":{"items":{"$ref":"#/components/schemas/ShotPromptAssetRef"},"type":"array","title":"Props","description":"道具引用"},"costumes":{"items":{"$ref":"#/components/schemas/ShotPromptAssetRef"},"type":"array","title":"Costumes","description":"服装引用"},"camera":{"$ref":"#/components/schemas/ShotPromptCameraInfo","description":"镜头语言"},"atmosphere":{"type":"string","title":"Atmosphere","description":"氛围描述","default":""},"visual_style":{"type":"string","title":"Visual Style","description":"项目视觉风格","default":""},"style":{"type":"string","title":"Style","description":"项目题材/风格","default":""},"negative_prompt":{"type":"string","title":"Negative Prompt","description":"默认负面提示词","default":""}},"type":"object","required":["shot_id"],"title":"ShotVideoPromptPackRead","description":"视频提示词渲染前的标准上下文包。"},"ShotVideoPromptPreviewRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"template_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Template Id","description":"使用的提示词模板 ID"},"template_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Template Name","description":"使用的提示词模板名称"},"rendered_prompt":{"type":"string","title":"Rendered Prompt","description":"渲染后的提示词"},"pack":{"$ref":"#/components/schemas/ShotVideoPromptPackRead","description":"渲染上下文包"},"warnings":{"items":{"type":"string"},"type":"array","title":"Warnings","description":"渲染时发现的非阻塞提示"}},"type":"object","required":["shot_id","rendered_prompt","pack"],"title":"ShotVideoPromptPreviewRead","description":"视频提示词预览结果。"},"ShotVideoReadinessCheck":{"properties":{"key":{"type":"string","title":"Key","description":"检查项 key"},"ok":{"type":"boolean","title":"Ok","description":"是否通过"},"message":{"type":"string","title":"Message","description":"面向前端展示的说明"}},"type":"object","required":["key","ok","message"],"title":"ShotVideoReadinessCheck","description":"单项视频生成准备度检查结果。"},"ShotVideoReadinessRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"reference_mode":{"type":"string","title":"Reference Mode","description":"参考模式"},"ready":{"type":"boolean","title":"Ready","description":"是否满足当前 reference_mode 下的视频生成条件"},"checks":{"items":{"$ref":"#/components/schemas/ShotVideoReadinessCheck"},"type":"array","title":"Checks","description":"准备度检查项"}},"type":"object","required":["shot_id","reference_mode","ready"],"title":"ShotVideoReadinessRead","description":"镜头视频生成准备度。"},"StudioAssetDraft":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"资产 ID(已落库时回填,如 scene_id / prop_id / costume_id)"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联的文件 ID(可空)"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图下载地址(可空)"},"name":{"type":"string","title":"Name","description":"名称(同项目内建议唯一)"},"description":{"type":"string","title":"Description","description":"描述","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"},"prompt_template_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prompt Template Id","description":"提示词模板 ID(可空)"},"view_count":{"type":"integer","minimum":1.0,"title":"View Count","description":"计划生成视角图数量","default":1}},"additionalProperties":false,"type":"object","required":["name"],"title":"StudioAssetDraft","description":"Studio 资产草稿(Scene/Prop/Costume)。\n\n导入 API 未传 id 时由服务端生成;分镜详情回填时可带 scene_id/prop_id/costume_id。"},"StudioCharacterDraft":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"角色 ID(已落库时回填 character_id)"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联的文件 ID(可空)"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图下载地址(可空)"},"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index","description":"镜头内角色排序(shot_character_links.index)"},"name":{"type":"string","title":"Name","description":"角色名称(同项目内建议唯一)"},"description":{"type":"string","title":"Description","description":"角色描述","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签(可选)"},"costume_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Name","description":"服装名称(可选,导入时映射到 costume_id)"},"prop_names":{"items":{"type":"string"},"type":"array","title":"Prop Names","description":"角色常用道具名称列表(可选)"}},"additionalProperties":false,"type":"object","required":["name"],"title":"StudioCharacterDraft","description":"Studio 角色草稿。\n\n导入 API 未传 id 时由服务端生成;分镜详情回填时可带 character_id。"},"StudioImageTaskRequest":{"properties":{"model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Id","description":"可选模型 ID(models.id);不传则使用 ModelSettings.default_image_model_id;Provider 由模型关联反查"},"image_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Image Id","description":"图片模型 ID,如 ActorImage.id / SceneImage.id / PropImage.id 等;必须与路径主体 ID 匹配"},"prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prompt","description":"提示词(由前端传入)。创建任务接口必填;render-prompt 接口可不传"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"参考图 file_id 列表(可多张,顺序有效)。创建任务接口会基于 file_id 从数据中解析为参考图"}},"type":"object","title":"StudioImageTaskRequest","description":"Studio 专用图片任务请求体:可选模型 ID,不传则用默认图片模型;供应商由模型反查。\n\nimage_id 表示具体的图片模型 ID,例如:\n- 演员图片:ActorImage.id\n- 场景图片:SceneImage.id\n- 道具图片:PropImage.id\n- 服装图片:CostumeImage.id\n- 角色图片:CharacterImage.id\n- 分镜帧图片:ShotFrameImage.id"},"StudioScriptExtractionDraft":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"项目 ID(必填)"},"chapter_id":{"type":"string","title":"Chapter Id","description":"章节 ID(必填,用于创建 shots/links)"},"script_text":{"type":"string","title":"Script Text","description":"剧本文本(可为优化后版本)"},"characters":{"items":{"$ref":"#/components/schemas/StudioCharacterDraft"},"type":"array","title":"Characters"},"scenes":{"items":{"$ref":"#/components/schemas/StudioAssetDraft"},"type":"array","title":"Scenes"},"props":{"items":{"$ref":"#/components/schemas/StudioAssetDraft"},"type":"array","title":"Props"},"costumes":{"items":{"$ref":"#/components/schemas/StudioAssetDraft"},"type":"array","title":"Costumes"},"shots":{"items":{"$ref":"#/components/schemas/StudioShotDraft"},"type":"array","title":"Shots","description":"镜头草稿列表"}},"additionalProperties":false,"type":"object","required":["project_id","chapter_id","script_text"],"title":"StudioScriptExtractionDraft","description":"用于导入 Studio 的提取结果草稿(name-based)。"},"StudioShotDraft":{"properties":{"index":{"type":"integer","minimum":1.0,"title":"Index","description":"镜头序号(章节内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题"},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"scene_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Name","description":"场景名称(可选)"},"character_names":{"items":{"type":"string"},"type":"array","title":"Character Names","description":"本镜出现角色名称列表"},"prop_names":{"items":{"type":"string"},"type":"array","title":"Prop Names","description":"本镜关键道具名称列表"},"costume_names":{"items":{"type":"string"},"type":"array","title":"Costume Names","description":"本镜服装名称列表"},"dialogue_lines":{"items":{"$ref":"#/components/schemas/StudioShotDraftDialogueLine"},"type":"array","title":"Dialogue Lines","description":"对白列表"},"actions":{"items":{"type":"string"},"type":"array","title":"Actions","description":"动作/场景描述"},"semantic_suggestion":{"anyOf":[{"$ref":"#/components/schemas/ShotSemanticSuggestion"},{"type":"null"}],"description":"镜头语言默认建议与动作拍点候选"}},"additionalProperties":false,"type":"object","required":["index","title"],"title":"StudioShotDraft","description":"镜头草稿:不含 shot_id,由导入 API 生成;引用实体用 name。"},"StudioShotDraftDialogueLine":{"properties":{"index":{"type":"integer","minimum":0.0,"title":"Index","description":"镜头内排序","default":0},"text":{"type":"string","title":"Text","description":"台词内容"},"line_mode":{"type":"string","enum":["DIALOGUE","VOICE_OVER","OFF_SCREEN","PHONE"],"title":"Line Mode","description":"对白模式","default":"DIALOGUE"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"说话角色名称(可空)"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"听者角色名称(可空)"}},"additionalProperties":false,"type":"object","required":["text"],"title":"StudioShotDraftDialogueLine","description":"镜头对白草稿:speaker/target 使用角色 name,导入时映射为 character_id。"},"StyleOption":{"properties":{"value":{"type":"string","title":"Value","description":"选项值"},"label":{"type":"string","title":"Label","description":"选项展示文案"}},"type":"object","required":["value","label"],"title":"StyleOption","description":"通用下拉选项。"},"TaskCancelRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/TaskStatus"},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已登记取消请求"},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"effective_immediately":{"type":"boolean","title":"Effective Immediately","description":"是否已立即取消完成","default":false}},"type":"object","required":["task_id","status","cancel_requested"],"title":"TaskCancelRead"},"TaskCancelRequest":{"properties":{"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason","description":"取消原因(可选)"}},"type":"object","title":"TaskCancelRequest"},"TaskCreated":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"任务 ID"}},"type":"object","required":["task_id"],"title":"TaskCreated"},"TaskLinkAdoptRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"link_type":{"type":"string","title":"Link Type","description":"project | chapter | shot"},"entity_id":{"type":"string","title":"Entity Id","description":"项目/章节/镜头 ID"},"is_adopted":{"type":"boolean","title":"Is Adopted","description":"是否采用(仅可正向变更为 true)"}},"type":"object","required":["task_id","link_type","entity_id","is_adopted"],"title":"TaskLinkAdoptRead","description":"采用状态更新结果。"},"TaskLinkAdoptRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"绑定项目 ID(可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"绑定章节 ID(可选)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"绑定镜头 ID(可选)"},"task_id":{"type":"string","title":"Task Id","description":"任务 ID"}},"type":"object","required":["task_id"],"title":"TaskLinkAdoptRequest","description":"更新采用状态请求:task_id + 三选一绑定对象(project_id/chapter_id/shot_id)。"},"TaskListItemRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"task_kind":{"type":"string","title":"Task Kind","description":"业务任务类型"},"status":{"$ref":"#/components/schemas/TaskStatus"},"progress":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Progress"},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已请求取消","default":false},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"started_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Started At Ts","description":"任务开始执行时间戳"},"finished_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Finished At Ts","description":"任务结束时间戳"},"elapsed_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Elapsed Ms","description":"任务累计执行耗时(毫秒)"},"created_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Created At Ts","description":"任务创建时间戳"},"updated_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Updated At Ts","description":"任务更新时间戳"},"executor_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Executor Type","description":"执行器类型,如 celery"},"executor_task_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Executor Task Id","description":"执行器侧任务 ID"},"relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Type","description":"业务关联类型"},"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"业务关联实体 ID"},"resource_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resource Type","description":"资源类型"},"navigate_relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Navigate Relation Type","description":"前端默认跳转关联类型"},"navigate_relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Navigate Relation Entity Id","description":"前端默认跳转关联实体 ID"}},"type":"object","required":["task_id","task_kind","status","progress"],"title":"TaskListItemRead"},"TaskResultRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/TaskStatus"},"progress":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Progress"},"result":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Result"},"error":{"type":"string","title":"Error","default":""},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已请求取消","default":false},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"started_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Started At Ts","description":"任务开始执行时间戳"},"finished_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Finished At Ts","description":"任务结束时间戳"},"elapsed_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Elapsed Ms","description":"任务累计执行耗时(毫秒)"}},"type":"object","required":["task_id","status","progress"],"title":"TaskResultRead"},"TaskStatus":{"type":"string","enum":["pending","running","streaming","succeeded","failed","cancelled"],"title":"TaskStatus","description":"任务状态枚举。"},"TaskStatusRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/TaskStatus"},"progress":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Progress"},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已请求取消","default":false},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"started_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Started At Ts","description":"任务开始执行时间戳"},"finished_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Finished At Ts","description":"任务结束时间戳"},"elapsed_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Elapsed Ms","description":"任务累计执行耗时(毫秒)"}},"type":"object","required":["task_id","status","progress"],"title":"TaskStatusRead"},"VFXType":{"type":"string","enum":["NONE","PARTICLES","VOLUMETRIC_FOG","CG_DOUBLE","DIGITAL_ENVIRONMENT","MATTE_PAINTING","FIRE_SMOKE","WATER_SIM","DESTRUCTION","ENERGY_MAGIC","COMPOSITING_CLEANUP","SLOW_MOTION_TIME","OTHER"],"title":"VFXType","description":"视效类型(与 `app.schemas.skills.common.VFXType` 对齐,存英文 code)。"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VariantAnalysisRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"merged_library":{"additionalProperties":true,"type":"object","title":"Merged Library","description":"合并后的实体库(EntityLibrary 的序列化形式;来自 EntityMerger 输出的 merged_library)"},"all_shot_extractions":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"All Shot Extractions","description":"所有镜头提取结果"},"script_division":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Script Division","description":"脚本分镜结果(可选;ScriptDivisionResult 序列化),用于章节/段落分组"}},"type":"object","required":["merged_library","all_shot_extractions"],"title":"VariantAnalysisRequest","description":"变体分析请求。"},"VariantAnalysisResult":{"properties":{"costume_timelines":{"items":{"$ref":"#/components/schemas/CostumeTimeline"},"type":"array","title":"Costume Timelines","description":"各角色服装演变时间线"},"variant_suggestions":{"items":{"$ref":"#/components/schemas/VariantSuggestion"},"type":"array","title":"Variant Suggestions","description":"变体建议列表"},"chapter_variants":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Chapter Variants","description":"章节变体建议"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"分析说明"}},"additionalProperties":false,"type":"object","title":"VariantAnalysisResult","description":"变体分析结果。"},"VariantSuggestion":{"properties":{"entity_id":{"type":"string","title":"Entity Id","description":"实体ID"},"entity_name":{"type":"string","title":"Entity Name","description":"实体名称"},"entity_type":{"type":"string","title":"Entity Type","description":"实体类型(character/scene/prop/location)"},"suggestion":{"type":"string","title":"Suggestion","description":"变体建议说明"},"affected_shots":{"items":{"type":"integer"},"type":"array","title":"Affected Shots","description":"涉及的镜头"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["entity_id","entity_name","entity_type","suggestion"],"title":"VariantSuggestion","description":"变体建议。"},"VideoGenerationOptionsRead":{"properties":{"provider":{"type":"string","title":"Provider","description":"供应商稳定键"},"model_id":{"type":"string","title":"Model Id","description":"默认视频模型 ID"},"model_name":{"type":"string","title":"Model Name","description":"默认视频模型名称"},"allowed_ratios":{"items":{"type":"string"},"type":"array","title":"Allowed Ratios","description":"当前模型允许的比例选项"},"default_ratio":{"type":"string","title":"Default Ratio","description":"当前模型默认比例"}},"type":"object","required":["provider","model_id","model_name","default_ratio"],"title":"VideoGenerationOptionsRead","description":"当前默认视频模型对应的生成参数选项。"},"VideoGenerationTaskRequest":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"reference_mode":{"type":"string","enum":["first","last","key","first_last","first_last_key","text_only"],"title":"Reference Mode","description":"参考模式:first | last | key | first_last | first_last_key | text_only"},"prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prompt","description":"视频提示词(text_only 必填)"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"参考图 file_id 列表,数量需与 reference_mode 严格匹配"},"ratio":{"type":"string","enum":["16:9","4:3","1:1","3:4","9:16","21:9"],"title":"Ratio","description":"视频画幅比例,如 16:9 / 9:16"}},"type":"object","required":["shot_id","reference_mode","ratio"],"title":"VideoGenerationTaskRequest","description":"视频生成任务请求。"},"VideoPromptPreviewResponse":{"properties":{"prompt":{"type":"string","title":"Prompt","description":"最终用于视频生成的提示词"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"关联参考图 file_id 列表"},"pack":{"anyOf":[{"$ref":"#/components/schemas/ShotVideoPromptPackRead"},{"type":"null"}],"description":"视频提示词预览上下文包"}},"type":"object","required":["prompt"],"title":"VideoPromptPreviewResponse"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Jellyfish API","version":"0.1.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"V1 Health","operationId":"v1_health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}}}}},"/api/v1/film/tasks/video/preview-prompt":{"post":{"tags":["film"],"summary":"视频提示词预览","description":"预览视频生成的提示词与自动关联参考图。","operationId":"preview_video_generation_prompt_api_v1_film_tasks_video_preview_prompt_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VideoPromptPreviewResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/video":{"post":{"tags":["film"],"summary":"视频生成(任务版)","description":"创建视频生成任务并后台执行,结果通过 /tasks/{task_id}/result 获取。","operationId":"create_video_generation_task_api_v1_film_tasks_video_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VideoGenerationTaskRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/shot-frame-prompts":{"post":{"tags":["film"],"summary":"镜头分镜帧提示词生成(任务版)","operationId":"create_shot_frame_prompt_task_api_v1_film_tasks_shot_frame_prompts_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFramePromptRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks":{"get":{"tags":["film"],"summary":"全局任务列表(任务中心)","operationId":"list_tasks_api_v1_film_tasks_get","parameters":[{"name":"statuses","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/TaskStatus"}},{"type":"null"}],"description":"按任务状态过滤,可多选","title":"Statuses"},"description":"按任务状态过滤,可多选"},{"name":"task_kind","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 task_kind 过滤","title":"Task Kind"},"description":"按 task_kind 过滤"},{"name":"relation_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_type 过滤","title":"Relation Type"},"description":"按 relation_type 过滤"},{"name":"relation_entity_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_entity_id 过滤","title":"Relation Entity Id"},"description":"按 relation_entity_id 过滤"},{"name":"recent_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"description":"默认返回最近结束任务的时间窗口(秒)","default":300,"title":"Recent Seconds"},"description":"默认返回最近结束任务的时间窗口(秒)"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":20,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_TaskListItemRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/status":{"get":{"tags":["film"],"summary":"查询任务状态/进度(轮询)","operationId":"get_task_status_api_v1_film_tasks__task_id__status_get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskStatusRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/result":{"get":{"tags":["film"],"summary":"获取任务结果","operationId":"get_task_result_api_v1_film_tasks__task_id__result_get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/tasks/{task_id}/cancel":{"post":{"tags":["film"],"summary":"请求取消任务","operationId":"cancel_task_api_v1_film_tasks__task_id__cancel_post","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskCancelRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCancelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/task-links/adopt":{"patch":{"tags":["film"],"summary":"更新任务关联的采用状态(仅可正向变更)","description":"将指定任务链接的状态设为 accepted;已采用不可改为未采用。","operationId":"adopt_task_link_api_v1_film_task_links_adopt_patch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskLinkAdoptRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskLinkAdoptRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/task-links":{"get":{"tags":["film"],"summary":"生成任务关联列表(分页,支持多条件过滤)","operationId":"list_task_links_api_v1_film_task_links_get","parameters":[{"name":"resource_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 resource_type 过滤","title":"Resource Type"},"description":"按 resource_type 过滤"},{"name":"relation_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_type 过滤","title":"Relation Type"},"description":"按 relation_type 过滤"},{"name":"relation_entity_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 relation_entity_id 过滤","title":"Relation Entity Id"},"description":"按 relation_entity_id 过滤"},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按关联状态过滤(accepted/todo/rejected)","title":"Status"},"description":"按关联状态过滤(accepted/todo/rejected)"},{"name":"task_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 task_id 过滤","title":"Task Id"},"description":"按 task_id 过滤"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段:updated_at/created_at/id/status","title":"Order"},"description":"排序字段:updated_at/created_at/id/status"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序;默认 true","default":true,"title":"Is Desc"},"description":"是否倒序;默认 true"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":10,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_GenerationTaskLinkRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["film"],"summary":"创建生成任务关联","operationId":"create_task_link_api_v1_film_task_links_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationTaskLinkCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_GenerationTaskLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/film/task-links/{link_id}":{"get":{"tags":["film"],"summary":"获取生成任务关联详情","operationId":"get_task_link_api_v1_film_task_links__link_id__get","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_GenerationTaskLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["film"],"summary":"更新生成任务关联(不支持直接修改 is_adopted)","operationId":"update_task_link_api_v1_film_task_links__link_id__patch","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationTaskLinkUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_GenerationTaskLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["film"],"summary":"删除生成任务关联","operationId":"delete_task_link_api_v1_film_task_links__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/providers":{"get":{"tags":["llm"],"summary":"列出模型供应商(分页)","operationId":"list_providers_api_v1_llm_providers_get","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段:name, created_at, updated_at","title":"Order"},"description":"排序字段:name, created_at, updated_at"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":10,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ProviderRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["llm"],"summary":"创建模型供应商","operationId":"create_provider_api_v1_llm_providers_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProviderRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/providers/supported":{"get":{"tags":["llm"],"summary":"列出系统支持的供应商能力","operationId":"list_supported_providers_api_v1_llm_providers_supported_get","parameters":[{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/ModelCategoryKey"},{"type":"null"}],"description":"按模型类别过滤:text/image/video","title":"Category"},"description":"按模型类别过滤:text/image/video"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ProviderSupportedRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/image-generation-options":{"get":{"tags":["llm"],"summary":"获取当前默认图片模型的关键帧规格选项","operationId":"get_image_generation_options_api_v1_llm_image_generation_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ImageGenerationOptionsRead_"}}}}}}},"/api/v1/llm/video-generation-options":{"get":{"tags":["llm"],"summary":"获取当前默认视频模型的动态比例选项","operationId":"get_video_generation_options_api_v1_llm_video_generation_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VideoGenerationOptionsRead_"}}}}}}},"/api/v1/llm/providers/{provider_id}":{"get":{"tags":["llm"],"summary":"获取单个模型供应商","operationId":"get_provider_api_v1_llm_providers__provider_id__get","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProviderRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["llm"],"summary":"更新模型供应商","operationId":"update_provider_api_v1_llm_providers__provider_id__patch","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProviderRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["llm"],"summary":"删除模型供应商","operationId":"delete_provider_api_v1_llm_providers__provider_id__delete","parameters":[{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","title":"Provider Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/models":{"get":{"tags":["llm"],"summary":"列出模型(分页)","operationId":"list_models_api_v1_llm_models_get","parameters":[{"name":"provider_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按供应商过滤","title":"Provider Id"},"description":"按供应商过滤"},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/ModelCategoryKey"},{"type":"null"}],"description":"按模型类别过滤","title":"Category"},"description":"按模型类别过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段:name, category, created_at, updated_at","title":"Order"},"description":"排序字段:name, category, created_at, updated_at"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"页码","default":1,"title":"Page"},"description":"页码"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"每页条数","default":10,"title":"Page Size"},"description":"每页条数"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ModelRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["llm"],"summary":"创建模型","operationId":"create_model_api_v1_llm_models_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/models/{model_id}":{"get":{"tags":["llm"],"summary":"获取单个模型","operationId":"get_model_api_v1_llm_models__model_id__get","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["llm"],"summary":"更新模型","operationId":"update_model_api_v1_llm_models__model_id__patch","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["llm"],"summary":"删除模型","operationId":"delete_model_api_v1_llm_models__model_id__delete","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/llm/model-settings":{"get":{"tags":["llm"],"summary":"获取模型全局设置(单例)","operationId":"get_model_settings_api_v1_llm_model_settings_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelSettingsRead_"}}}}}},"put":{"tags":["llm"],"summary":"更新模型全局设置(单例)","operationId":"update_model_settings_api_v1_llm_model_settings_put","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelSettingsUpdate"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ModelSettingsRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/projects/style-options":{"get":{"tags":["studio/projects"],"summary":"获取项目风格候选项","operationId":"get_project_style_options_api_v1_studio_projects_style_options_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectStyleOptionsRead_"}}}}}}},"/api/v1/studio/projects":{"get":{"tags":["studio/projects"],"summary":"项目列表(分页)","operationId":"list_projects_api_v1_studio_projects_get","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段","title":"Order"},"description":"排序字段"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ProjectRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/projects"],"summary":"创建项目","operationId":"create_project_api_v1_studio_projects_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/projects/{project_id}":{"get":{"tags":["studio/projects"],"summary":"获取项目","operationId":"get_project_api_v1_studio_projects__project_id__get","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/projects"],"summary":"更新项目","operationId":"update_project_api_v1_studio_projects__project_id__patch","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/projects"],"summary":"删除项目","operationId":"delete_project_api_v1_studio_projects__project_id__delete","parameters":[{"name":"project_id","in":"path","required":true,"schema":{"type":"string","title":"Project Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/chapters":{"get":{"tags":["studio/chapters"],"summary":"章节列表(分页)","operationId":"list_chapters_api_v1_studio_chapters_get","parameters":[{"name":"project_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按项目过滤","title":"Project Id"},"description":"按项目过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 title/summary","title":"Q"},"description":"关键字,过滤 title/summary"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"排序字段","title":"Order"},"description":"排序字段"},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","description":"是否倒序","default":false,"title":"Is Desc"},"description":"是否倒序"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ChapterRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/chapters"],"summary":"创建章节","operationId":"create_chapter_api_v1_studio_chapters_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChapterCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ChapterRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/chapters/{chapter_id}":{"get":{"tags":["studio/chapters"],"summary":"获取章节","operationId":"get_chapter_api_v1_studio_chapters__chapter_id__get","parameters":[{"name":"chapter_id","in":"path","required":true,"schema":{"type":"string","title":"Chapter Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ChapterRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/chapters"],"summary":"更新章节","operationId":"update_chapter_api_v1_studio_chapters__chapter_id__patch","parameters":[{"name":"chapter_id","in":"path","required":true,"schema":{"type":"string","title":"Chapter Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChapterUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ChapterRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/chapters"],"summary":"删除章节","operationId":"delete_chapter_api_v1_studio_chapters__chapter_id__delete","parameters":[{"name":"chapter_id","in":"path","required":true,"schema":{"type":"string","title":"Chapter Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots":{"get":{"tags":["studio/shots"],"summary":"镜头列表(分页)","operationId":"list_shots_api_v1_studio_shots_get","parameters":[{"name":"chapter_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按章节过滤","title":"Chapter Id"},"description":"按章节过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 title/script_excerpt","title":"Q"},"description":"关键字,过滤 title/script_excerpt"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shots"],"summary":"创建镜头","operationId":"create_shot_api_v1_studio_shots_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/runtime-summary":{"get":{"tags":["studio/shots"],"summary":"按章节获取镜头运行时任务态摘要","operationId":"list_shot_runtime_summary_api_v1_studio_shots_runtime_summary_get","parameters":[{"name":"chapter_id","in":"query","required":true,"schema":{"type":"string","description":"章节 ID","title":"Chapter Id"},"description":"章节 ID"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotRuntimeSummaryRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/extraction-draft":{"get":{"tags":["studio/shots"],"summary":"分镜详情:按镜头关联拼装 StudioScriptExtractionDraft","operationId":"get_shot_extraction_draft_api_v1_studio_shots__shot_id__extraction_draft_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_StudioScriptExtractionDraft_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/extracted-candidates":{"get":{"tags":["studio/shots"],"summary":"获取镜头提取候选项","operationId":"get_shot_extracted_candidates_api_v1_studio_shots__shot_id__extracted_candidates_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotExtractedCandidateRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/extracted-dialogue-candidates":{"get":{"tags":["studio/shots"],"summary":"获取镜头提取对白候选项","operationId":"get_shot_extracted_dialogue_candidates_api_v1_studio_shots__shot_id__extracted_dialogue_candidates_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotExtractedDialogueCandidateRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/assets-overview":{"get":{"tags":["studio/shots"],"summary":"获取镜头资产总览(已关联资产 + 提取候选)","operationId":"get_shot_assets_overview_api_api_v1_studio_shots__shot_id__assets_overview_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotAssetsOverviewRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/preparation-state":{"get":{"tags":["studio/shots"],"summary":"获取镜头准备页聚合状态","operationId":"get_shot_preparation_state_api_api_v1_studio_shots__shot_id__preparation_state_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationStateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/preparation-link":{"post":{"tags":["studio/shots"],"summary":"准备页关联现有实体并返回最新聚合状态","operationId":"link_existing_asset_for_preparation_api_api_v1_studio_shots__shot_id__preparation_link_post","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotPreparationLinkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/video-prompt-preview":{"get":{"tags":["studio/shots"],"summary":"预览镜头视频提示词","operationId":"preview_shot_video_prompt_api_v1_studio_shots__shot_id__video_prompt_preview_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}},{"name":"template_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"指定视频提示词模板 ID;不传则使用默认模板","title":"Template Id"},"description":"指定视频提示词模板 ID;不传则使用默认模板"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotVideoPromptPreviewRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/video-readiness":{"get":{"tags":["studio/shots"],"summary":"获取镜头视频生成准备度","operationId":"get_shot_video_readiness_api_api_v1_studio_shots__shot_id__video_readiness_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}},{"name":"reference_mode","in":"query","required":false,"schema":{"type":"string","description":"参考模式:first/last/key/first_last/first_last_key/text_only","default":"text_only","title":"Reference Mode"},"description":"参考模式:first/last/key/first_last/first_last_key/text_only"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotVideoReadinessRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/skip-extraction":{"patch":{"tags":["studio/shots"],"summary":"设置是否跳过镜头信息提取","operationId":"update_shot_skip_extraction_api_v1_studio_shots__shot_id__skip_extraction_patch","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotSkipExtractionUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-candidates/{candidate_id}/link":{"patch":{"tags":["studio/shots"],"summary":"确认并关联镜头提取候选项","operationId":"link_extracted_candidate_api_v1_studio_shots_extracted_candidates__candidate_id__link_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotExtractedCandidateLinkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-candidates/{candidate_id}/ignore":{"patch":{"tags":["studio/shots"],"summary":"忽略镜头提取候选项","operationId":"ignore_extracted_candidate_api_v1_studio_shots_extracted_candidates__candidate_id__ignore_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-dialogue-candidates/{candidate_id}/accept":{"patch":{"tags":["studio/shots"],"summary":"接受镜头提取对白候选项","operationId":"accept_extracted_dialogue_candidate_api_v1_studio_shots_extracted_dialogue_candidates__candidate_id__accept_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"requestBody":{"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ShotExtractedDialogueCandidateAcceptRequest"},{"type":"null"}],"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/extracted-dialogue-candidates/{candidate_id}/ignore":{"patch":{"tags":["studio/shots"],"summary":"忽略镜头提取对白候选项","operationId":"ignore_extracted_dialogue_candidate_api_v1_studio_shots_extracted_dialogue_candidates__candidate_id__ignore_patch","parameters":[{"name":"candidate_id","in":"path","required":true,"schema":{"type":"integer","title":"Candidate Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotPreparationMutationResultRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}":{"get":{"tags":["studio/shots"],"summary":"获取镜头","operationId":"get_shot_api_v1_studio_shots__shot_id__get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/shots"],"summary":"更新镜头","operationId":"update_shot_api_v1_studio_shots__shot_id__patch","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shots"],"summary":"删除镜头","operationId":"delete_shot_api_v1_studio_shots__shot_id__delete","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shots/{shot_id}/linked-assets":{"get":{"tags":["studio/shots"],"summary":"获取镜头关联的角色/道具/场景/服装(分页)","operationId":"list_shot_linked_assets_api_v1_studio_shots__shot_id__linked_assets_get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotLinkedAssetItem__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-details":{"get":{"tags":["studio/shot-details"],"summary":"镜头细节列表(分页)","operationId":"list_shot_details_api_v1_studio_shot_details_get","parameters":[{"name":"shot_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按镜头过滤(id 同 shot_id)","title":"Shot Id"},"description":"按镜头过滤(id 同 shot_id)"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotDetailRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-details"],"summary":"创建镜头细节","operationId":"create_shot_detail_api_v1_studio_shot_details_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDetailCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-details/{shot_id}":{"get":{"tags":["studio/shot-details"],"summary":"获取镜头细节","operationId":"get_shot_detail_api_v1_studio_shot_details__shot_id__get","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/shot-details"],"summary":"更新镜头细节","operationId":"update_shot_detail_api_v1_studio_shot_details__shot_id__patch","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDetailUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shot-details"],"summary":"删除镜头细节","operationId":"delete_shot_detail_api_v1_studio_shot_details__shot_id__delete","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-dialog-lines":{"get":{"tags":["studio/shot-dialog-lines"],"summary":"镜头对话行列表(分页)","operationId":"list_shot_dialog_lines_api_v1_studio_shot_dialog_lines_get","parameters":[{"name":"shot_detail_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按镜头细节过滤","title":"Shot Detail Id"},"description":"按镜头细节过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 text","title":"Q"},"description":"关键字,过滤 text"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotDialogLineRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-dialog-lines"],"summary":"创建镜头对话行","operationId":"create_shot_dialog_line_api_v1_studio_shot_dialog_lines_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDialogLineCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDialogLineRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-dialog-lines/{line_id}":{"patch":{"tags":["studio/shot-dialog-lines"],"summary":"更新镜头对话行","operationId":"update_shot_dialog_line_api_v1_studio_shot_dialog_lines__line_id__patch","parameters":[{"name":"line_id","in":"path","required":true,"schema":{"type":"integer","title":"Line Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotDialogLineUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotDialogLineRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shot-dialog-lines"],"summary":"删除镜头对话行","operationId":"delete_shot_dialog_line_api_v1_studio_shot_dialog_lines__line_id__delete","parameters":[{"name":"line_id","in":"path","required":true,"schema":{"type":"integer","title":"Line Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/{entity_type}":{"get":{"tags":["studio/shot-links"],"summary":"项目-章节-镜头-实体关联列表(分页)","operationId":"list_project_entity_links_api_v1_studio_shot_links__entity_type__get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"project_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"}},{"name":"chapter_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"}},{"name":"shot_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"}},{"name":"asset_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Asset Id"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/actor":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-演员关联","operationId":"create_project_actor_link_api_v1_studio_shot_links_actor_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectActorLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/actor/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-演员关联","operationId":"delete_project_actor_link_api_v1_studio_shot_links_actor__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/scene":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-场景关联","operationId":"create_project_scene_link_api_v1_studio_shot_links_scene_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectSceneLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/scene/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-场景关联","operationId":"delete_project_scene_link_api_v1_studio_shot_links_scene__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/prop":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-道具关联","operationId":"create_project_prop_link_api_v1_studio_shot_links_prop_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectPropLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/prop/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-道具关联","operationId":"delete_project_prop_link_api_v1_studio_shot_links_prop__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/costume":{"post":{"tags":["studio/shot-links"],"summary":"创建项目-章节-镜头-服装关联","operationId":"create_project_costume_link_api_v1_studio_shot_links_costume_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectAssetLinkCreate"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProjectCostumeLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-links/costume/{link_id}":{"delete":{"tags":["studio/shot-links"],"summary":"删除项目-章节-镜头-服装关联","operationId":"delete_project_costume_link_api_v1_studio_shot_links_costume__link_id__delete","parameters":[{"name":"link_id","in":"path","required":true,"schema":{"type":"integer","title":"Link Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-frame-images":{"get":{"tags":["studio/shot-frame-images"],"summary":"镜头分镜帧图片列表(分页)","operationId":"list_shot_frame_images_api_v1_studio_shot_frame_images_get","parameters":[{"name":"shot_detail_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按镜头细节过滤","title":"Shot Detail Id"},"description":"按镜头细节过滤"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_ShotFrameImageRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-frame-images"],"summary":"创建镜头分镜帧图片","operationId":"create_shot_frame_image_api_v1_studio_shot_frame_images_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFrameImageCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotFrameImageRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-frame-images/{image_id}":{"patch":{"tags":["studio/shot-frame-images"],"summary":"更新镜头分镜帧图片","operationId":"update_shot_frame_image_api_v1_studio_shot_frame_images__image_id__patch","parameters":[{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFrameImageUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotFrameImageRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/shot-frame-images"],"summary":"删除镜头分镜帧图片","operationId":"delete_shot_frame_image_api_v1_studio_shot_frame_images__image_id__delete","parameters":[{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/existence-check":{"post":{"tags":["studio/entities"],"summary":"批量检测资产名称是否存在(模糊匹配,不分页)","operationId":"check_entity_names_existence_api_v1_studio_entities_existence_check_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityNameExistenceCheckRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_EntityNameExistenceCheckResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}":{"get":{"tags":["studio/entities"],"summary":"统一实体列表(分页)","operationId":"list_entities_api_v1_studio_entities__entity_type__get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name/description","title":"Q"},"description":"关键字,过滤 name/description"},{"name":"style","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"题材/风格(单值)","title":"Style"},"description":"题材/风格(单值)"},{"name":"visual_style","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"画面表现形式(单值:真人/动漫)","title":"Visual Style"},"description":"画面表现形式(单值:真人/动漫)"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_dict_str__Any___"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/entities"],"summary":"统一创建实体","operationId":"create_entity_api_v1_studio_entities__entity_type__post","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}/{entity_id}":{"get":{"tags":["studio/entities"],"summary":"统一获取实体","operationId":"get_entity_api_v1_studio_entities__entity_type___entity_id__get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/entities"],"summary":"统一更新实体","operationId":"update_entity_api_v1_studio_entities__entity_type___entity_id__patch","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/entities"],"summary":"统一删除实体","operationId":"delete_entity_api_v1_studio_entities__entity_type___entity_id__delete","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}/{entity_id}/images":{"get":{"tags":["studio/entities"],"summary":"统一实体图片列表(分页)","operationId":"list_entity_images_api_v1_studio_entities__entity_type___entity_id__images_get","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_dict_str__Any___"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/entities"],"summary":"统一创建实体图片","operationId":"create_entity_image_api_v1_studio_entities__entity_type___entity_id__images_post","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/entities/{entity_type}/{entity_id}/images/{image_id}":{"patch":{"tags":["studio/entities"],"summary":"统一更新实体图片","operationId":"update_entity_image_api_v1_studio_entities__entity_type___entity_id__images__image_id__patch","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}},{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_str__Any__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/entities"],"summary":"统一删除实体图片","operationId":"delete_entity_image_api_v1_studio_entities__entity_type___entity_id__images__image_id__delete","parameters":[{"name":"entity_type","in":"path","required":true,"schema":{"type":"string","title":"Entity Type"}},{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","title":"Entity Id"}},{"name":"image_id","in":"path","required":true,"schema":{"type":"integer","title":"Image Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/prompts":{"get":{"tags":["studio/prompts"],"summary":"提示词模板列表(分页)","operationId":"list_prompt_templates_api_v1_studio_prompts_get","parameters":[{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/PromptCategory"},{"type":"null"}],"description":"按类别过滤","title":"Category"},"description":"按类别过滤"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name","title":"Q"},"description":"关键字,过滤 name"},{"name":"is_default","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"过滤是否为默认","title":"Is Default"},"description":"过滤是否为默认"},{"name":"is_system","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"过滤是否为系统预置","title":"Is System"},"description":"过滤是否为系统预置"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_PromptTemplateRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/prompts"],"summary":"创建提示词模板","operationId":"create_prompt_template_api_v1_studio_prompts_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptTemplateCreate"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PromptTemplateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/prompts/categories":{"get":{"tags":["studio/prompts"],"summary":"获取提示词类别枚举(含中文映射)","operationId":"list_prompt_categories_api_v1_studio_prompts_categories_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_PromptCategoryOptionRead__"}}}}}}},"/api/v1/studio/prompts/{template_id}":{"get":{"tags":["studio/prompts"],"summary":"获取提示词模板详情","operationId":"get_prompt_template_api_v1_studio_prompts__template_id__get","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","title":"Template Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PromptTemplateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/prompts"],"summary":"局部更新提示词模板","operationId":"update_prompt_template_api_v1_studio_prompts__template_id__patch","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","title":"Template Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptTemplateUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PromptTemplateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/prompts"],"summary":"删除提示词模板","operationId":"delete_prompt_template_api_v1_studio_prompts__template_id__delete","parameters":[{"name":"template_id","in":"path","required":true,"schema":{"type":"string","title":"Template Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files":{"get":{"tags":["studio/files"],"summary":"文件列表(分页)","operationId":"list_files_api_api_v1_studio_files_get","parameters":[{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"关键字,过滤 name","title":"Q"},"description":"关键字,过滤 name"},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Order"}},{"name":"is_desc","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Is Desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":10,"title":"Page Size"}},{"name":"project_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 file_usages 限定项目;提供后仅返回该项目下有关联记录的文件","title":"Project Id"},"description":"按 file_usages 限定项目;提供后仅返回该项目下有关联记录的文件"},{"name":"chapter_title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"章节标题(精确匹配,与 project_id 联用)","title":"Chapter Title"},"description":"章节标题(精确匹配,与 project_id 联用)"},{"name":"shot_title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"镜头标题(精确匹配,与 project_id 联用)","title":"Shot Title"},"description":"镜头标题(精确匹配,与 project_id 联用)"},{"name":"chapter_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 file_usages.chapter_id 精确过滤(与 project_id 联用;比标题稳定)","title":"Chapter Id"},"description":"按 file_usages.chapter_id 精确过滤(与 project_id 联用;比标题稳定)"},{"name":"usage_kind","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"按 file_usages.usage_kind 精确过滤,如 subtitle(与 project_id 联用)","title":"Usage Kind"},"description":"按 file_usages.usage_kind 精确过滤,如 subtitle(与 project_id 联用)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PaginatedData_FileRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/upload":{"post":{"tags":["studio/files"],"summary":"上传文件并创建 FileItem 记录","operationId":"upload_file_api_api_v1_studio_files_upload_post","parameters":[{"name":"name","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_file_api_api_v1_studio_files_upload_post"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_FileRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/{file_id}/download":{"get":{"tags":["studio/files"],"summary":"下载文件二进制内容","operationId":"download_file_api_api_v1_studio_files__file_id__download_get","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/{file_id}/storage-info":{"get":{"tags":["studio/files"],"summary":"获取对象存储详情(head_object)","operationId":"get_file_storage_info_api_api_v1_studio_files__file_id__storage_info_get","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/files/{file_id}":{"get":{"tags":["studio/files"],"summary":"获取文件详情(元信息 + file_usages)","operationId":"get_file_detail_api_v1_studio_files__file_id__get","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_FileDetailRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["studio/files"],"summary":"更新文件元信息","operationId":"update_file_meta_api_v1_studio_files__file_id__patch","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_FileRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["studio/files"],"summary":"删除文件(记录 + 存储对象)","operationId":"delete_file_api_api_v1_studio_files__file_id__delete","parameters":[{"name":"file_id","in":"path","required":true,"schema":{"type":"string","title":"File Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_NoneType_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/actors/{actor_id}/image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"演员图片生成(任务版)","description":"为指定演员创建图片生成任务,并通过 `GenerationTaskLink` 关联。","operationId":"create_actor_image_generation_task_api_v1_studio_image_tasks_actors__actor_id__image_tasks_post","parameters":[{"name":"actor_id","in":"path","required":true,"schema":{"type":"string","title":"Actor Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/actors/{actor_id}/render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"演员图片提示词渲染","operationId":"render_actor_image_prompt_api_v1_studio_image_tasks_actors__actor_id__render_prompt_post","parameters":[{"name":"actor_id","in":"path","required":true,"schema":{"type":"string","title":"Actor Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedPromptResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/assets/{asset_type}/{asset_id}/image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"道具/场景/服装图片生成(任务版)","description":"为道具/场景/服装创建图片生成任务。\n\n- asset_type: prop / scene / costume\n- path 参数 asset_id 为对应资产 ID\n- body.image_id 必须为该资产下对应图片表记录的 ID(PropImage/SceneImage/CostumeImage)","operationId":"create_asset_image_generation_task_api_v1_studio_image_tasks_assets__asset_type___asset_id__image_tasks_post","parameters":[{"name":"asset_type","in":"path","required":true,"schema":{"type":"string","title":"Asset Type"}},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string","title":"Asset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/assets/{asset_type}/{asset_id}/render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"道具/场景/服装图片提示词渲染","operationId":"render_asset_image_prompt_api_v1_studio_image_tasks_assets__asset_type___asset_id__render_prompt_post","parameters":[{"name":"asset_type","in":"path","required":true,"schema":{"type":"string","title":"Asset Type"}},{"name":"asset_id","in":"path","required":true,"schema":{"type":"string","title":"Asset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedPromptResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/characters/{character_id}/image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"角色图片生成(任务版)","description":"为角色创建图片生成任务(对应 CharacterImage 业务)。\n\n- path 参数 character_id 为 Character.id\n- body.image_id 必须为该角色下的 CharacterImage.id","operationId":"create_character_image_generation_task_api_v1_studio_image_tasks_characters__character_id__image_tasks_post","parameters":[{"name":"character_id","in":"path","required":true,"schema":{"type":"string","title":"Character Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/characters/{character_id}/render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"角色图片提示词渲染","operationId":"render_character_image_prompt_api_v1_studio_image_tasks_characters__character_id__render_prompt_post","parameters":[{"name":"character_id","in":"path","required":true,"schema":{"type":"string","title":"Character Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioImageTaskRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedPromptResponse_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/shot/{shot_id}/frame-image-tasks":{"post":{"tags":["studio/image-tasks"],"summary":"镜头分镜帧图片生成(任务版)","description":"为镜头分镜帧图片生成任务(基于 `shot_id + frame_type` 自动定位数据)。","operationId":"create_shot_frame_image_generation_task_api_v1_studio_image_tasks_shot__shot_id__frame_image_tasks_post","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFrameImageTaskRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_TaskCreated_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/image-tasks/shot/{shot_id}/frame-render-prompt":{"post":{"tags":["studio/image-tasks"],"summary":"镜头分镜帧提示词渲染","operationId":"render_shot_frame_prompt_api_v1_studio_image_tasks_shot__shot_id__frame_render_prompt_post","parameters":[{"name":"shot_id","in":"path","required":true,"schema":{"type":"string","title":"Shot Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotFramePromptRenderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_RenderedShotFramePromptRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/studio/shot-character-links":{"get":{"tags":["studio/shot-character-links"],"summary":"查询镜头角色关联列表(ShotCharacterLink)","operationId":"list_shot_character_links_api_v1_studio_shot_character_links_get","parameters":[{"name":"shot_id","in":"query","required":true,"schema":{"type":"string","description":"镜头 ID","title":"Shot Id"},"description":"镜头 ID"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ShotCharacterLinkRead__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["studio/shot-character-links"],"summary":"创建/更新镜头角色关联(ShotCharacterLink)","operationId":"upsert_shot_character_link_api_v1_studio_shot_character_links_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShotCharacterLinkCreate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ShotCharacterLinkRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/divide-async":{"post":{"tags":["script-processing"],"summary":"异步将剧本分割为多个镜头","description":"创建章节分镜提取任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"divide_script_async_api_v1_script_processing_divide_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptDividerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/divide":{"post":{"tags":["script-processing"],"summary":"将剧本分割为多个镜头","description":"输入完整剧本文本,输出分镜列表(index/start_line/end_line/script_excerpt/shot_name/time_of_day)。注意:此阶段不强制稳定ID,角色以“称呼/名字”弱信息输出,稳定ID在合并阶段统一分配。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 divide-async。","operationId":"divide_script_api_v1_script_processing_divide_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptDividerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptDivisionResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/merge-entities-async":{"post":{"tags":["script-processing"],"summary":"异步合并多镜头的实体信息","description":"创建实体合并任务并立即返回 task_id;当前保留为预备能力,尚无真实前端入口。","operationId":"merge_entities_async_api_v1_script_processing_merge_entities_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityMergerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/merge-entities":{"post":{"tags":["script-processing"],"summary":"合并多镜头的实体信息","description":"输入全部分镜提取结果(可选带上脚本分镜与历史实体库),输出合并后的实体库:角色库/地点库/场景库/道具库(静态画像 + 变体列表)。该步骤会统一分配稳定ID(如 char_001/loc_001/prop_001/scene_001)。当提供 previous_merge 与 conflict_resolutions 时,将进行冲突重试合并,优先消解 conflicts 并尽量保持 ID 稳定。当前接口保留为预备能力,尚无真实前端入口。","operationId":"merge_entities_api_v1_script_processing_merge_entities_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityMergerRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_EntityMergeResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-variants-async":{"post":{"tags":["script-processing"],"summary":"异步分析服装/外形变体","description":"创建变体分析任务并立即返回 task_id;当前保留为预备能力,尚无真实前端入口。","operationId":"analyze_variants_async_api_v1_script_processing_analyze_variants_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VariantAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-variants":{"post":{"tags":["script-processing"],"summary":"分析服装/外形变体","description":"检测角色服装/外形变化,构建演变时间线,生成章节变体建议列表与变体建议。当前接口保留为预备能力,尚无真实前端入口。","operationId":"analyze_variants_api_v1_script_processing_analyze_variants_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VariantAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_VariantAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/check-consistency-async":{"post":{"tags":["script-processing"],"summary":"异步检查角色混淆一致性(基于原文)","description":"创建一致性检查任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"check_consistency_async_api_v1_script_processing_check_consistency_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptConsistencyCheckRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/check-consistency":{"post":{"tags":["script-processing"],"summary":"检查角色混淆一致性(基于原文)","description":"检测同一角色在不同段落/镜头被赋予不同身份/行为主体导致混淆,并给出修改建议。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 check-consistency-async。","operationId":"check_consistency_api_v1_script_processing_check_consistency_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptConsistencyCheckRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptConsistencyCheckResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-character-portrait-async":{"post":{"tags":["script-processing"],"summary":"异步分析人物画像缺失信息","description":"创建人物画像分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_character_portrait_async_api_v1_script_processing_analyze_character_portrait_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CharacterPortraitAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-character-portrait":{"post":{"tags":["script-processing"],"summary":"分析人物画像缺失信息","description":"根据原文人物上下文与人物描述,判断缺少哪些关键信息,并给出优化后的人物画像描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-character-portrait-async。","operationId":"analyze_character_portrait_api_v1_script_processing_analyze_character_portrait_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CharacterPortraitAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_CharacterPortraitAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-prop-info-async":{"post":{"tags":["script-processing"],"summary":"异步分析道具信息缺失项","description":"创建道具信息分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_prop_info_async_api_v1_script_processing_analyze_prop_info_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-prop-info":{"post":{"tags":["script-processing"],"summary":"分析道具信息缺失项","description":"根据原文道具上下文与道具描述,判断缺少哪些关键信息,并给出优化后的可生成道具描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-prop-info-async。","operationId":"analyze_prop_info_api_v1_script_processing_analyze_prop_info_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_PropInfoAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-scene-info-async":{"post":{"tags":["script-processing"],"summary":"异步分析场景信息缺失项","description":"创建场景信息分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_scene_info_async_api_v1_script_processing_analyze_scene_info_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SceneInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-scene-info":{"post":{"tags":["script-processing"],"summary":"分析场景信息缺失项","description":"根据原文场景上下文与场景描述,判断缺少哪些关键信息,并给出优化后的可生成场景描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-scene-info-async。","operationId":"analyze_scene_info_api_v1_script_processing_analyze_scene_info_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SceneInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_SceneInfoAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-costume-info-async":{"post":{"tags":["script-processing"],"summary":"异步分析服装信息缺失项","description":"创建服装信息分析任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"analyze_costume_info_async_api_v1_script_processing_analyze_costume_info_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CostumeInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/analyze-costume-info":{"post":{"tags":["script-processing"],"summary":"分析服装信息缺失项","description":"根据原文服装上下文与服装描述,判断缺少哪些关键信息,并给出优化后的可生成服装描述。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 analyze-costume-info-async。","operationId":"analyze_costume_info_api_v1_script_processing_analyze_costume_info_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CostumeInfoAnalysisRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_CostumeInfoAnalysisResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/optimize-script-async":{"post":{"tags":["script-processing"],"summary":"异步基于一致性检查优化剧本","description":"创建剧本优化任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"optimize_script_async_api_v1_script_processing_optimize_script_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptOptimizeRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/optimize-script":{"post":{"tags":["script-processing"],"summary":"基于一致性检查优化剧本","description":"将一致性检查输出及原文作为输入,生成优化后的剧本(尽量少改,只改与角色混淆 issues 相关段落)。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 optimize-script-async。","operationId":"optimize_script_api_v1_script_processing_optimize_script_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptOptimizeRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptOptimizationResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/simplify-script":{"post":{"tags":["script-processing"],"summary":"智能精简剧本","description":"在保留剧情主体并保证剧情连续的前提下精简剧本文本。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 simplify-script-async。","operationId":"simplify_script_api_v1_script_processing_simplify_script_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptSimplifyRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ScriptSimplificationResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/simplify-script-async":{"post":{"tags":["script-processing"],"summary":"异步智能精简剧本","description":"创建剧本精简任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"simplify_script_async_api_v1_script_processing_simplify_script_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptSimplifyRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/extract-async":{"post":{"tags":["script-processing"],"summary":"异步项目级信息提取(最终输出)","description":"创建项目级信息提取任务并立即返回 task_id;前端可通过任务状态接口轮询。","operationId":"extract_script_async_api_v1_script_processing_extract_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptExtractRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_AsyncTaskCreateRead_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/script-processing/extract":{"post":{"tags":["script-processing"],"summary":"项目级信息提取(最终输出)","description":"输入分镜结果(可选带一致性检查结果),输出可导入 Studio 的草稿结构(name-based,ID 由导入接口生成)。当前同步接口主要用于兼容旧调用与调试场景;页面主流程优先使用 extract-async。","operationId":"extract_script_api_v1_script_processing_extract_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScriptExtractRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_StudioScriptExtractionDraft_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/crypto-animal-studio/health":{"get":{"tags":["crypto-animal-studio"],"summary":"Cas Health","description":"返回 CAS 模块健康状态与契约版本。\n\n返回:\n 统一 ``ApiResponse`` 壳,data 形如\n ``{\"service\": \"crypto-animal-studio\", \"status\": \"ok\", \"schema_version\": \"1.0\"}``。","operationId":"cas_health_api_v1_crypto_animal_studio_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_dict_"}}}}}}},"/api/v1/crypto-animal-studio/import":{"post":{"tags":["crypto-animal-studio"],"summary":"Import Episode Endpoint","description":"导入一个 EpisodePackage 为一个 Jellyfish Chapter(含 Shots 等)。\n\n返回:统一 ``ApiResponse``,data 为 ImportResult。\n错误:项目不存在→404;幂等冲突/重复导入→409;契约校验失败→422(由 pydantic);\nCAS QA 闸门失败→422(零写入)。","operationId":"import_episode_endpoint_api_v1_crypto_animal_studio_import_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportEpisodeRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ImportResult_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/crypto-animal-studio/import/async":{"post":{"tags":["crypto-animal-studio"],"summary":"Import Episode Async Endpoint","description":"把导入登记为任务中心的 ``cas_import_episode_package`` 任务并立即返回。\n\n请求体与同步端点完全一致(同一个 ``ImportEpisodeRequest``),因此契约校验行为不变。\n真正的导入由 ``run_cas_import_task`` 驱动,成功/失败通过既有任务状态查询接口获取。\n\n返回:统一 ``ApiResponse``,data 为任务受理信息(``reused=true`` 表示复用活动任务)。","operationId":"import_episode_async_endpoint_api_v1_crypto_animal_studio_import_async_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportEpisodeRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_CasImportTaskAccepted_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/crypto-animal-studio/production/jobs":{"post":{"tags":["crypto-animal-studio","crypto-animal-studio/production"],"summary":"Create Production Job","description":"创建并同步执行一次生产(每次调用创建新任务)。","operationId":"create_production_job_api_v1_crypto_animal_studio_production_jobs_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductionJobRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProductionJobView_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/crypto-animal-studio/production/jobs/{job_id}":{"get":{"tags":["crypto-animal-studio","crypto-animal-studio/production"],"summary":"Get Production Job","description":"查询生产任务状态。","operationId":"get_production_job_api_v1_crypto_animal_studio_production_jobs__job_id__get","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProductionJobView_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/crypto-animal-studio/production/jobs/{job_id}/artifacts":{"get":{"tags":["crypto-animal-studio","crypto-animal-studio/production"],"summary":"List Production Artifacts","description":"列出任务的全部产物。","operationId":"list_production_artifacts_api_v1_crypto_animal_studio_production_jobs__job_id__artifacts_get","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_list_ProductionArtifactView__"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/crypto-animal-studio/production/jobs/{job_id}/retry":{"post":{"tags":["crypto-animal-studio","crypto-animal-studio/production"],"summary":"Retry Production Job","description":"从失败阶段重试(复用更早的有效产物)。","operationId":"retry_production_job_api_v1_crypto_animal_studio_production_jobs__job_id__retry_post","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetryProductionJobRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse_ProductionJobView_"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"summary":"Health","description":"健康检查。","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"ActionBeatPhaseRead":{"properties":{"text":{"type":"string","title":"Text","description":"动作拍点原文"},"phase":{"type":"string","enum":["trigger","peak","aftermath"],"title":"Phase","description":"推断阶段:触发 / 峰值 / 收束"}},"type":"object","required":["text","phase"],"title":"ActionBeatPhaseRead","description":"动作拍点的轻量阶段推断结果。"},"ActorAsset":{"properties":{"actor_key":{"type":"string","minLength":1,"title":"Actor Key","description":"演员键(素材类别内唯一)"},"display_name":{"type":"string","title":"Display Name","description":"展示名","default":""},"description":{"type":"string","title":"Description","description":"外观/视觉描述","default":""}},"additionalProperties":false,"type":"object","required":["actor_key"],"title":"ActorAsset","description":"视觉演员素材(跨角色/跨集可复用的视觉身份)。"},"ApiResponse_AsyncTaskCreateRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/AsyncTaskCreateRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[AsyncTaskCreateRead]"},"ApiResponse_CasImportTaskAccepted_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/CasImportTaskAccepted"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[CasImportTaskAccepted]"},"ApiResponse_ChapterRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ChapterRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ChapterRead]"},"ApiResponse_CharacterPortraitAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/CharacterPortraitAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[CharacterPortraitAnalysisResult]"},"ApiResponse_CostumeInfoAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/CostumeInfoAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[CostumeInfoAnalysisResult]"},"ApiResponse_EntityMergeResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/EntityMergeResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[EntityMergeResult]"},"ApiResponse_EntityNameExistenceCheckResponse_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/EntityNameExistenceCheckResponse"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[EntityNameExistenceCheckResponse]"},"ApiResponse_FileDetailRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/FileDetailRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[FileDetailRead]"},"ApiResponse_FileRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/FileRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[FileRead]"},"ApiResponse_GenerationTaskLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/GenerationTaskLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[GenerationTaskLinkRead]"},"ApiResponse_ImageGenerationOptionsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ImageGenerationOptionsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ImageGenerationOptionsRead]"},"ApiResponse_ImportResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ImportResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ImportResult]"},"ApiResponse_ModelRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ModelRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ModelRead]"},"ApiResponse_ModelSettingsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ModelSettingsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ModelSettingsRead]"},"ApiResponse_NoneType_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"type":"null","title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[NoneType]"},"ApiResponse_PaginatedData_Any__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_Any_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[Any]]"},"ApiResponse_PaginatedData_ChapterRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ChapterRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ChapterRead]]"},"ApiResponse_PaginatedData_FileRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_FileRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[FileRead]]"},"ApiResponse_PaginatedData_GenerationTaskLinkRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_GenerationTaskLinkRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[GenerationTaskLinkRead]]"},"ApiResponse_PaginatedData_ModelRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ModelRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ModelRead]]"},"ApiResponse_PaginatedData_ProjectRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ProjectRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ProjectRead]]"},"ApiResponse_PaginatedData_PromptTemplateRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_PromptTemplateRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[PromptTemplateRead]]"},"ApiResponse_PaginatedData_ProviderRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ProviderRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ProviderRead]]"},"ApiResponse_PaginatedData_ShotDetailRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotDetailRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotDetailRead]]"},"ApiResponse_PaginatedData_ShotDialogLineRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotDialogLineRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotDialogLineRead]]"},"ApiResponse_PaginatedData_ShotFrameImageRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotFrameImageRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotFrameImageRead]]"},"ApiResponse_PaginatedData_ShotLinkedAssetItem__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotLinkedAssetItem_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotLinkedAssetItem]]"},"ApiResponse_PaginatedData_ShotRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_ShotRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[ShotRead]]"},"ApiResponse_PaginatedData_TaskListItemRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_TaskListItemRead_"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[TaskListItemRead]]"},"ApiResponse_PaginatedData_dict_str__Any___":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PaginatedData_dict_str__Any__"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PaginatedData[dict[str, Any]]]"},"ApiResponse_ProductionJobView_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProductionJobView"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProductionJobView]"},"ApiResponse_ProjectActorLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectActorLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectActorLinkRead]"},"ApiResponse_ProjectCostumeLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectCostumeLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectCostumeLinkRead]"},"ApiResponse_ProjectPropLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectPropLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectPropLinkRead]"},"ApiResponse_ProjectRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectRead]"},"ApiResponse_ProjectSceneLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectSceneLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectSceneLinkRead]"},"ApiResponse_ProjectStyleOptionsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProjectStyleOptionsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProjectStyleOptionsRead]"},"ApiResponse_PromptTemplateRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PromptTemplateRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PromptTemplateRead]"},"ApiResponse_PropInfoAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PropInfoAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[PropInfoAnalysisResult]"},"ApiResponse_ProviderRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ProviderRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ProviderRead]"},"ApiResponse_RenderedPromptResponse_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/RenderedPromptResponse"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[RenderedPromptResponse]"},"ApiResponse_RenderedShotFramePromptRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/RenderedShotFramePromptRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[RenderedShotFramePromptRead]"},"ApiResponse_SceneInfoAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/SceneInfoAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[SceneInfoAnalysisResult]"},"ApiResponse_ScriptConsistencyCheckResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptConsistencyCheckResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptConsistencyCheckResult]"},"ApiResponse_ScriptDivisionResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptDivisionResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptDivisionResult]"},"ApiResponse_ScriptOptimizationResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptOptimizationResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptOptimizationResult]"},"ApiResponse_ScriptSimplificationResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ScriptSimplificationResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ScriptSimplificationResult]"},"ApiResponse_ShotAssetsOverviewRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotAssetsOverviewRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotAssetsOverviewRead]"},"ApiResponse_ShotCharacterLinkRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotCharacterLinkRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotCharacterLinkRead]"},"ApiResponse_ShotDetailRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotDetailRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotDetailRead]"},"ApiResponse_ShotDialogLineRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotDialogLineRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotDialogLineRead]"},"ApiResponse_ShotFrameImageRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotFrameImageRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotFrameImageRead]"},"ApiResponse_ShotPreparationMutationResultRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotPreparationMutationResultRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotPreparationMutationResultRead]"},"ApiResponse_ShotPreparationStateRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotPreparationStateRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotPreparationStateRead]"},"ApiResponse_ShotRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotRead]"},"ApiResponse_ShotVideoPromptPreviewRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotVideoPromptPreviewRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotVideoPromptPreviewRead]"},"ApiResponse_ShotVideoReadinessRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/ShotVideoReadinessRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[ShotVideoReadinessRead]"},"ApiResponse_StudioScriptExtractionDraft_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/StudioScriptExtractionDraft"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[StudioScriptExtractionDraft]"},"ApiResponse_TaskCancelRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskCancelRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskCancelRead]"},"ApiResponse_TaskCreated_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskCreated"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskCreated]"},"ApiResponse_TaskLinkAdoptRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskLinkAdoptRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskLinkAdoptRead]"},"ApiResponse_TaskResultRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskResultRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskResultRead]"},"ApiResponse_TaskStatusRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/TaskStatusRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[TaskStatusRead]"},"ApiResponse_VariantAnalysisResult_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/VariantAnalysisResult"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[VariantAnalysisResult]"},"ApiResponse_VideoGenerationOptionsRead_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/VideoGenerationOptionsRead"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[VideoGenerationOptionsRead]"},"ApiResponse_VideoPromptPreviewResponse_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"$ref":"#/components/schemas/VideoPromptPreviewResponse"},{"type":"null"}],"description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[VideoPromptPreviewResponse]"},"ApiResponse_dict_":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[dict]"},"ApiResponse_dict_str__Any__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[dict[str, Any]]"},"ApiResponse_list_ProductionArtifactView__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ProductionArtifactView"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ProductionArtifactView]]"},"ApiResponse_list_PromptCategoryOptionRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/PromptCategoryOptionRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[PromptCategoryOptionRead]]"},"ApiResponse_list_ProviderSupportedRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ProviderSupportedRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ProviderSupportedRead]]"},"ApiResponse_list_ShotCharacterLinkRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotCharacterLinkRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotCharacterLinkRead]]"},"ApiResponse_list_ShotExtractedCandidateRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotExtractedCandidateRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotExtractedCandidateRead]]"},"ApiResponse_list_ShotExtractedDialogueCandidateRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotExtractedDialogueCandidateRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotExtractedDialogueCandidateRead]]"},"ApiResponse_list_ShotRuntimeSummaryRead__":{"properties":{"code":{"type":"integer","title":"Code","description":"与 HTTP 状态码一致","default":200},"message":{"type":"string","title":"Message","description":"提示信息","default":"success"},"data":{"anyOf":[{"items":{"$ref":"#/components/schemas/ShotRuntimeSummaryRead"},"type":"array"},{"type":"null"}],"title":"Data","description":"实际数据"},"meta":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Meta","description":"附加元信息"}},"type":"object","title":"ApiResponse[list[ShotRuntimeSummaryRead]]"},"AssetLibrary":{"properties":{"actors":{"items":{"$ref":"#/components/schemas/ActorAsset"},"type":"array","title":"Actors","description":"演员素材列表"},"scenes":{"items":{"$ref":"#/components/schemas/SceneAsset"},"type":"array","title":"Scenes","description":"场景素材列表"},"props":{"items":{"$ref":"#/components/schemas/PropAsset"},"type":"array","title":"Props","description":"道具素材列表"},"costumes":{"items":{"$ref":"#/components/schemas/CostumeAsset"},"type":"array","title":"Costumes","description":"服装素材列表"}},"additionalProperties":false,"type":"object","title":"AssetLibrary","description":"一集的素材库:演员 / 场景 / 道具 / 服装。"},"AsyncTaskCreateRead":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"任务 ID"},"status":{"$ref":"#/components/schemas/TaskStatus","description":"任务状态"},"reused":{"type":"boolean","title":"Reused","description":"是否复用了当前业务实体已有的活跃任务","default":false},"relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Type","description":"业务关联类型"},"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"业务关联实体 ID"}},"type":"object","required":["task_id","status"],"title":"AsyncTaskCreateRead"},"Body_upload_file_api_api_v1_studio_files_upload_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File","description":"要上传的二进制文件"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"可选:写入 file_usages 的项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"},"usage_kind":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Usage Kind","description":"与 project_id 同时提供时写入 file_usages"},"source_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Ref"}},"type":"object","required":["file"],"title":"Body_upload_file_api_api_v1_studio_files_upload_post"},"CameraAngle":{"type":"string","enum":["EYE_LEVEL","HIGH_ANGLE","LOW_ANGLE","BIRD_EYE","DUTCH","OVER_SHOULDER"],"title":"CameraAngle","description":"机位角度(与 `app.schemas.skills.common.CameraAngle` 对齐,存英文 code)。"},"CameraMovement":{"type":"string","enum":["STATIC","PAN","TILT","DOLLY_IN","DOLLY_OUT","TRACK","CRANE","HANDHELD","STEADICAM","ZOOM_IN","ZOOM_OUT"],"title":"CameraMovement","description":"运镜方式(与 `app.schemas.skills.common.CameraMovement` 对齐,存英文 code)。"},"CameraShotType":{"type":"string","enum":["ECU","CU","MCU","MS","MLS","LS","ELS"],"title":"CameraShotType","description":"景别(与 `app.schemas.skills.common.ShotType` 对齐,存英文 code)。"},"CameraSpec":{"properties":{"shot_type":{"anyOf":[{"$ref":"#/components/schemas/CasShotType"},{"type":"null"}],"description":"景别(ECU/CU/MCU/MS/MLS/LS/ELS)"},"angle":{"anyOf":[{"$ref":"#/components/schemas/CasCameraAngle"},{"type":"null"}],"description":"机位角度(EYE_LEVEL/HIGH_ANGLE/LOW_ANGLE/BIRD_EYE/DUTCH/OVER_SHOULDER)"},"movement":{"anyOf":[{"$ref":"#/components/schemas/CasCameraMovement"},{"type":"null"}],"description":"运镜(STATIC/PAN/TILT/DOLLY_IN/DOLLY_OUT/TRACK/CRANE/HANDHELD/STEADICAM/ZOOM_IN/ZOOM_OUT)"}},"additionalProperties":false,"type":"object","title":"CameraSpec","description":"镜头的结构化相机描述。\n\nv1.1 起将「camera 自由文本」升级为结构化对象,字段与 Jellyfish ShotDetail 的\n``camera_shot`` / ``angle`` / ``movement`` 概念一一对应,便于导入器干净映射。\n三个字段均可选(storyboard 未指定时留空);取值由 CAS 本地枚举校验,\n**不**从 Jellyfish ORM/枚举导入。"},"CasCameraAngle":{"type":"string","enum":["EYE_LEVEL","HIGH_ANGLE","LOW_ANGLE","BIRD_EYE","DUTCH","OVER_SHOULDER"],"title":"CasCameraAngle","description":"机位角度(对齐 Jellyfish CameraAngle 的 code)。"},"CasCameraMovement":{"type":"string","enum":["STATIC","PAN","TILT","DOLLY_IN","DOLLY_OUT","TRACK","CRANE","HANDHELD","STEADICAM","ZOOM_IN","ZOOM_OUT"],"title":"CasCameraMovement","description":"运镜方式(对齐 Jellyfish CameraMovement 的 code)。"},"CasImportTaskAccepted":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"任务中心任务 ID"},"status":{"type":"string","title":"Status","description":"任务状态(pending/running/...)"},"reused":{"type":"boolean","title":"Reused","description":"是否复用了同一剧集的活动任务"},"task_kind":{"type":"string","title":"Task Kind","description":"任务种类(cas_import_episode_package)"},"relation_type":{"type":"string","title":"Relation Type","description":"业务关联类型"},"relation_entity_id":{"type":"string","title":"Relation Entity Id","description":"业务关联实体键(project+episode 摘要)"}},"additionalProperties":false,"type":"object","required":["task_id","status","reused","task_kind","relation_type","relation_entity_id"],"title":"CasImportTaskAccepted","description":"POST /import/async 的响应体:任务已受理。"},"CasShotType":{"type":"string","enum":["ECU","CU","MCU","MS","MLS","LS","ELS"],"title":"CasShotType","description":"景别(对齐 Jellyfish CameraShotType 的 code)。"},"ChapterCreate":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"所属项目 ID"},"index":{"type":"integer","title":"Index","description":"章节序号(项目内唯一)"},"title":{"type":"string","title":"Title","description":"章节标题"},"summary":{"type":"string","title":"Summary","description":"章节摘要","default":""},"raw_text":{"type":"string","title":"Raw Text","description":"章节原文","default":""},"condensed_text":{"type":"string","title":"Condensed Text","description":"精简原文","default":""},"storyboard_count":{"type":"integer","title":"Storyboard Count","description":"分镜数量","default":0},"status":{"$ref":"#/components/schemas/ChapterStatus","description":"章节状态","default":"draft"},"id":{"type":"string","title":"Id","description":"章节 ID"}},"type":"object","required":["project_id","index","title","id"],"title":"ChapterCreate"},"ChapterRead":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"所属项目 ID"},"index":{"type":"integer","title":"Index","description":"章节序号(项目内唯一)"},"title":{"type":"string","title":"Title","description":"章节标题"},"summary":{"type":"string","title":"Summary","description":"章节摘要","default":""},"raw_text":{"type":"string","title":"Raw Text","description":"章节原文","default":""},"condensed_text":{"type":"string","title":"Condensed Text","description":"精简原文","default":""},"storyboard_count":{"type":"integer","title":"Storyboard Count","description":"分镜数量","default":0},"status":{"$ref":"#/components/schemas/ChapterStatus","description":"章节状态","default":"draft"},"id":{"type":"string","title":"Id"},"shot_count":{"type":"integer","title":"Shot Count","description":"分镜数(shots 条数聚合)","default":0}},"type":"object","required":["project_id","index","title","id"],"title":"ChapterRead"},"ChapterStatus":{"type":"string","enum":["draft","shooting","done"],"title":"ChapterStatus","description":"章节生产状态。"},"ChapterUpdate":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id"},"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"raw_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Raw Text"},"condensed_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Condensed Text"},"storyboard_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Storyboard Count"},"status":{"anyOf":[{"$ref":"#/components/schemas/ChapterStatus"},{"type":"null"}]}},"type":"object","title":"ChapterUpdate"},"CharacterPortraitAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"character_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Character Context","description":"原文人物上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"character_description":{"type":"string","minLength":1,"title":"Character Description","description":"原文人物描述"}},"type":"object","required":["character_description"],"title":"CharacterPortraitAnalysisRequest","description":"人物画像缺失信息分析请求。"},"CharacterPortraitAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"CharacterPortraitAnalysisResult","description":"根据原文人物描述,分析缺少的信息,并给出优化后的可生成画像描述。"},"CharacterSpec":{"properties":{"character_key":{"type":"string","minLength":1,"title":"Character Key","description":"角色键(本集内唯一,非空)"},"display_name":{"type":"string","minLength":1,"title":"Display Name","description":"展示名(如 Bull)"},"role":{"type":"string","title":"Role","description":"叙事角色定位(如 main、chaos_agent、straight_man)","default":""},"description":{"type":"string","title":"Description","description":"角色描述","default":""},"actor_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Actor Key","description":"对应 assets.actors 中的 actor_key(可选)"},"costume_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Key","description":"对应 assets.costumes 中的 costume_key(可选)"},"voice_profile":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Voice Profile","description":"声音设定(可选)"},"continuity_notes":{"type":"string","title":"Continuity Notes","description":"角色连续性备注(可选)","default":""}},"additionalProperties":false,"type":"object","required":["character_key","display_name"],"title":"CharacterSpec","description":"出场角色定义(叙事角色)。\n\n``character_key`` 为本集内稳定引用键;``actor_key`` / ``costume_key`` 指向素材库\n(视觉演员 / 服装),用于 Jellyfish 侧的一致性与选角映射。"},"CostumeAsset":{"properties":{"costume_key":{"type":"string","minLength":1,"title":"Costume Key","description":"服装键(素材类别内唯一)"},"display_name":{"type":"string","title":"Display Name","description":"展示名","default":""},"description":{"type":"string","title":"Description","description":"服装描述","default":""}},"additionalProperties":false,"type":"object","required":["costume_key"],"title":"CostumeAsset","description":"服装素材。"},"CostumeInfoAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"costume_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Context","description":"原文服装上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"costume_description":{"type":"string","minLength":1,"title":"Costume Description","description":"原文服装描述"}},"type":"object","required":["costume_description"],"title":"CostumeInfoAnalysisRequest","description":"服装信息缺失分析请求。"},"CostumeInfoAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"CostumeInfoAnalysisResult","description":"根据原文服装/造型描述,分析缺少的信息,并给出优化后的可生成服装描述。"},"CostumeTimeline":{"properties":{"character_id":{"type":"string","title":"Character Id","description":"角色稳定ID"},"character_name":{"type":"string","title":"Character Name","description":"角色名称"},"timeline_entries":{"items":{"$ref":"#/components/schemas/CostumeTimelineEntry"},"type":"array","title":"Timeline Entries","description":"时间线条目"}},"additionalProperties":false,"type":"object","required":["character_id","character_name"],"title":"CostumeTimeline","description":"单角色的服装演变时间线。"},"CostumeTimelineEntry":{"properties":{"shot_index":{"type":"integer","minimum":1.0,"title":"Shot Index","description":"镜头序号"},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id","description":"可选:所属场景稳定ID(若已可推断)"},"costume_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Note","description":"服装/外形要点(简短)"},"changes":{"items":{"type":"string"},"type":"array","title":"Changes","description":"与上一条相比的变化点"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["shot_index"],"title":"CostumeTimelineEntry","description":"单角色的服装演变时间线条目。"},"CreateProductionJobRequest":{"properties":{"project_id":{"type":"string","minLength":1,"title":"Project Id","description":"项目 ID"},"episode_package":{"anyOf":[{"$ref":"#/components/schemas/EpisodePackageV11"},{"$ref":"#/components/schemas/EpisodePackage"}],"title":"Episode Package","description":"待生产的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1)"},"mode":{"type":"string","const":"mock","title":"Mode","description":"供应商模式;本冲刺仅支持 mock","default":"mock"}},"additionalProperties":false,"type":"object","required":["project_id","episode_package"],"title":"CreateProductionJobRequest","description":"POST /production/jobs 请求体。"},"CreativeDirection":{"properties":{"format":{"type":"string","title":"Format","description":"内容格式(如 short_form_vertical)","default":""},"tone":{"type":"string","title":"Tone","description":"整体基调(如 deadpan、satirical)","default":""},"target_duration_seconds":{"type":"integer","exclusiveMinimum":0.0,"title":"Target Duration Seconds","description":"目标时长(秒),必须大于零"},"visual_style":{"type":"string","title":"Visual Style","description":"视觉风格(如 anime、cel-shaded)","default":""},"comedy_style":{"type":"string","title":"Comedy Style","description":"喜剧风格(如 false_confidence + callback)","default":""},"continuity_notes":{"type":"string","title":"Continuity Notes","description":"连续性备注:跨集/跨镜需保持的设定","default":""}},"additionalProperties":false,"type":"object","required":["target_duration_seconds"],"title":"CreativeDirection","description":"一集的创意方向:格式、基调、时长目标与风格。"},"DataLock":{"properties":{"status":{"type":"string","enum":["unresolved","locked"],"title":"Status","description":"锁定状态","default":"unresolved"},"locked_at_utc":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Locked At Utc","description":"锁定时间(ISO-8601)"}},"additionalProperties":false,"type":"object","title":"DataLock","description":"市场数据锁定状态。"},"DialogueLine":{"properties":{"order":{"type":"integer","exclusiveMinimum":0.0,"title":"Order","description":"镜头内排序(正整数,镜头内唯一)"},"character_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Character Key","description":"说话角色键(可选;旁白可为空)"},"text":{"type":"string","minLength":1,"title":"Text","description":"台词正文(非空)"},"line_mode":{"type":"string","title":"Line Mode","description":"对白模式:DIALOGUE/VOICE_OVER/OFF_SCREEN/PHONE","default":"DIALOGUE"}},"additionalProperties":false,"type":"object","required":["order","text"],"title":"DialogueLine","description":"镜头内单条对白。\n\n``order`` 为镜头内排序(正整数、镜头内唯一);``character_key`` 若提供,\n必须能在 ``characters`` 中找到(根模型统一校验)。"},"DialogueLineMode":{"type":"string","enum":["DIALOGUE","VOICE_OVER","OFF_SCREEN","PHONE"],"title":"DialogueLineMode","description":"对白模式(与 `app.schemas.skills.common.DialogueLineMode` 对齐,存英文 code)。"},"EntityEntry":{"properties":{"id":{"type":"string","title":"Id","description":"实体稳定ID(合并阶段分配)"},"name":{"type":"string","title":"Name","description":"实体名称"},"type":{"type":"string","enum":["character","scene","prop","location"],"title":"Type","description":"实体类型"},"normalized_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Normalized Name","description":"归一化名称(来自文本,可选)"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases","description":"别名/称呼(来自文本,可选)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"基础画像/描述(忠实文本,简短)"},"confidence":{"anyOf":[{"type":"number","maximum":1.0,"minimum":0.0},{"type":"null"}],"title":"Confidence","description":"合并确定度 0-1(可选)"},"first_appearance":{"anyOf":[{"$ref":"#/components/schemas/EvidenceSpan"},{"type":"null"}],"description":"首次出场证据(可选)"},"costume_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Note","description":"服装/造型描述(可选,便于变体与资产关联)"},"traits":{"items":{"type":"string"},"type":"array","title":"Traits","description":"性格/特征词(可选)"},"location_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location Type","description":"地点类型:房间/街道/森林/车厢等(可选)"},"category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category","description":"道具类别(可选:weapon/document/vehicle/clothing/device/magic_item/other)"},"owner_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Character Id","description":"拥有者角色ID(可选)"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"支撑该实体画像的证据片段(可选)"},"first_shot":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"First Shot","description":"首次出现的镜头序号"},"appearances":{"items":{"type":"integer"},"type":"array","title":"Appearances","description":"出现镜头列表"},"variants":{"items":{"$ref":"#/components/schemas/EntityVariant"},"type":"array","title":"Variants","description":"变体列表"}},"additionalProperties":false,"type":"object","required":["id","name","type"],"title":"EntityEntry","description":"合并后的实体条目(脚本处理中间态)。"},"EntityLibrary":{"properties":{"characters":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Characters","description":"角色库"},"locations":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Locations","description":"地点库"},"scenes":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Scenes","description":"场景库"},"props":{"items":{"$ref":"#/components/schemas/EntityEntry"},"type":"array","title":"Props","description":"道具库"},"total_entries":{"type":"integer","minimum":0.0,"title":"Total Entries","description":"总实体数"}},"additionalProperties":false,"type":"object","required":["total_entries"],"title":"EntityLibrary","description":"合并后的实体库(脚本处理中间态)。"},"EntityMergeResult":{"properties":{"merged_library":{"$ref":"#/components/schemas/EntityLibrary","description":"合并后的实体库"},"merge_stats":{"additionalProperties":true,"type":"object","title":"Merge Stats","description":"合并统计信息"},"conflicts":{"items":{"type":"string"},"type":"array","title":"Conflicts","description":"发现的冲突/待处理项"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"合并说明"}},"additionalProperties":false,"type":"object","required":["merged_library"],"title":"EntityMergeResult","description":"实体合并结果(脚本处理中间态)。"},"EntityMergerRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"all_shot_extractions":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"All Shot Extractions","description":"所有镜头提取结果(ShotElementExtractionResult 的序列化形式)"},"historical_library":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Historical Library","description":"历史实体库(可选,用于增量合并)"},"script_division":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Script Division","description":"脚本分镜结果(可选;ScriptDivisionResult 序列化),用于定位与统计"},"previous_merge":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Previous Merge","description":"上一次合并结果(可选;EntityMergeResult 序列化),用于冲突重试合并"},"conflict_resolutions":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Conflict Resolutions","description":"冲突解决建议列表(可选;用于冲突重试合并)"}},"type":"object","required":["all_shot_extractions"],"title":"EntityMergerRequest","description":"实体合并请求。"},"EntityNameExistenceCheckRequest":{"properties":{"project_id":{"type":"string","minLength":1,"title":"Project Id","description":"项目 ID(必填)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可选;不传则 linked_to_shot 恒为 false)"},"character_names":{"items":{"type":"string"},"type":"array","title":"Character Names","description":"角色名称列表"},"prop_names":{"items":{"type":"string"},"type":"array","title":"Prop Names","description":"道具名称列表"},"scene_names":{"items":{"type":"string"},"type":"array","title":"Scene Names","description":"场景名称列表"},"costume_names":{"items":{"type":"string"},"type":"array","title":"Costume Names","description":"服装名称列表"}},"additionalProperties":false,"type":"object","required":["project_id"],"title":"EntityNameExistenceCheckRequest","description":"批量检测项目内/全局资产名称是否存在(模糊匹配)。"},"EntityNameExistenceCheckResponse":{"properties":{"characters":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Characters"},"props":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Props"},"scenes":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Scenes"},"costumes":{"items":{"$ref":"#/components/schemas/EntityNameExistenceItem"},"type":"array","title":"Costumes"}},"additionalProperties":false,"type":"object","title":"EntityNameExistenceCheckResponse","description":"批量存在性检测结果(按资产类型分组)。"},"EntityNameExistenceItem":{"properties":{"name":{"type":"string","title":"Name","description":"输入名称(原样回传)"},"exists":{"type":"boolean","title":"Exists","description":"数据库中是否存在(模糊命中)"},"linked_to_project":{"type":"boolean","title":"Linked To Project","description":"是否已关联到该项目(角色等同于 exists)"},"linked_to_shot":{"type":"boolean","title":"Linked To Shot","description":"是否已关联到请求中的 shot(未传 shot_id 时为 false)","default":false},"asset_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Asset Id","description":"命中的资产 ID(如 prop_id/scene_id/costume_id/character_id)"},"link_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Link Id","description":"若已关联到项目,对应 Project*Link 的 id;否则为空"}},"additionalProperties":false,"type":"object","required":["name","exists","linked_to_project"],"title":"EntityNameExistenceItem","description":"单个名称的存在性结果。"},"EntityVariant":{"properties":{"variant_key":{"type":"string","title":"Variant Key","description":"变体键(例如 outfit_v1、wounded_state 等)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"变体描述(简短)"},"affected_shots":{"items":{"type":"integer"},"type":"array","title":"Affected Shots","description":"涉及镜头序号"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["variant_key"],"title":"EntityVariant","description":"实体变体条目(最小可用结构,便于服装/外形演变)。"},"EpisodeMetadata":{"properties":{"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At","description":"生成时间(ISO-8601 字符串,可选)"},"generator":{"type":"string","title":"Generator","description":"生成器标识(如 creative-os)","default":""},"model":{"type":"string","title":"Model","description":"所用模型标识","default":""},"prompt_version":{"type":"string","title":"Prompt Version","description":"提示词版本","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"}},"additionalProperties":false,"type":"object","title":"EpisodeMetadata","description":"一集的生成元信息(用于追溯)。"},"EpisodePackage":{"properties":{"schema_version":{"type":"string","title":"Schema Version","description":"契约版本;v1 必须等于 \"1.0\""},"episode_id":{"type":"string","minLength":1,"title":"Episode Id","description":"一集的唯一 ID(非空)"},"title":{"type":"string","minLength":1,"title":"Title","description":"剧集标题(非空)"},"logline":{"type":"string","title":"Logline","description":"一句话梗概","default":""},"language":{"type":"string","minLength":1,"title":"Language","description":"语言(如 en、zh;非空)"},"source":{"$ref":"#/components/schemas/NewsSource","description":"素材来源"},"creative_direction":{"$ref":"#/components/schemas/CreativeDirection","description":"创意方向"},"characters":{"items":{"$ref":"#/components/schemas/CharacterSpec"},"type":"array","title":"Characters","description":"出场角色(键须唯一)"},"assets":{"$ref":"#/components/schemas/AssetLibrary","description":"素材库"},"shots":{"items":{"$ref":"#/components/schemas/Shot"},"type":"array","minItems":1,"title":"Shots","description":"镜头列表(至少一个)"},"metadata":{"$ref":"#/components/schemas/EpisodeMetadata","description":"生成元信息"}},"additionalProperties":false,"type":"object","required":["schema_version","episode_id","title","language","source","creative_direction","characters","assets","shots","metadata"],"title":"EpisodePackage","description":"EpisodePackage v1 根对象:一集的完整交付包。\n\n一个 EpisodePackage 对应 Jellyfish 的一个 Chapter;其 ``shots`` 直接建立\nJellyfish 的 Shot(不回送 ScriptDivider)。跨引用完整性由 ``_validate_cross_references``\n统一校验。"},"EpisodePackageV11":{"properties":{"schema_version":{"type":"string","title":"Schema Version","description":"契约版本;v1 必须等于 \"1.0\""},"episode_id":{"type":"string","minLength":1,"title":"Episode Id","description":"一集的唯一 ID(非空)"},"title":{"type":"string","minLength":1,"title":"Title","description":"剧集标题(非空)"},"logline":{"type":"string","title":"Logline","description":"一句话梗概","default":""},"language":{"type":"string","minLength":1,"title":"Language","description":"语言(如 en、zh;非空)"},"source":{"$ref":"#/components/schemas/NewsSource","description":"素材来源"},"creative_direction":{"$ref":"#/components/schemas/CreativeDirection","description":"创意方向"},"characters":{"items":{"$ref":"#/components/schemas/CharacterSpec"},"type":"array","title":"Characters","description":"出场角色(键须唯一)"},"assets":{"$ref":"#/components/schemas/AssetLibrary","description":"素材库"},"shots":{"items":{"$ref":"#/components/schemas/ShotV11"},"type":"array","minItems":1,"title":"Shots","description":"镜头列表(至少一个)"},"metadata":{"$ref":"#/components/schemas/EpisodeMetadata","description":"生成元信息"},"output":{"anyOf":[{"$ref":"#/components/schemas/OutputSpec"},{"type":"null"}],"description":"输出规格(缺省时用文档化默认值)"},"localization":{"anyOf":[{"$ref":"#/components/schemas/Localization"},{"type":"null"}],"description":"口语与字幕"},"fact_card":{"anyOf":[{"$ref":"#/components/schemas/FactCard"},{"type":"null"}],"description":"后期 fact card"},"market_data":{"anyOf":[{"$ref":"#/components/schemas/MarketData"},{"type":"null"}],"description":"市场事实溯源"},"references":{"anyOf":[{"$ref":"#/components/schemas/References"},{"type":"null"}],"description":"Bible 与参考资产"},"post_production":{"anyOf":[{"$ref":"#/components/schemas/PostProduction"},{"type":"null"}],"description":"后期叠加计划"}},"additionalProperties":false,"type":"object","required":["schema_version","episode_id","title","language","source","creative_direction","characters","assets","shots","metadata"],"title":"EpisodePackageV11","description":"EpisodePackage v1.1 根对象:v1 全部字段 + 六个可选顶层对象;shots 使用 ShotV11。"},"EvidenceSpan":{"properties":{"chunk_id":{"type":"string","title":"Chunk Id","description":"输入文本块的唯一ID(例如 chapter1_p03)"},"start_char":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Start Char","description":"在该 chunk 中的起始字符位置(可选)"},"end_char":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"End Char","description":"在该 chunk 中的结束字符位置(可选)"},"quote":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Quote","description":"不超过200字的原文摘录(可选,便于人工审核)"}},"additionalProperties":false,"type":"object","required":["chunk_id"],"title":"EvidenceSpan","description":"可追溯证据:原文定位(chunk + 起止位置/摘录),用于审核与回查。"},"FactCard":{"properties":{"duration_ms":{"type":"integer","exclusiveMinimum":0.0,"title":"Duration Ms","description":"卡片时长(毫秒)"},"placement":{"type":"string","enum":["append_after_shots","overlay_tail"],"title":"Placement","description":"追加式才计入总时长","default":"append_after_shots"},"readable_text_in_post":{"type":"boolean","const":true,"title":"Readable Text In Post","description":"卡面文字一律后期合成","default":true},"localized":{"items":{"$ref":"#/components/schemas/FactCardLocalizedCopy"},"type":"array","minItems":1,"title":"Localized","description":"各语言文案"}},"additionalProperties":false,"type":"object","required":["duration_ms","localized"],"title":"FactCard","description":"后期 fact card;**永远不是第五个生成镜头**。"},"FactCardLocalizedCopy":{"properties":{"language_tag":{"type":"string","minLength":1,"title":"Language Tag","description":"BCP 47 语言标签"},"body":{"items":{"type":"string"},"type":"array","title":"Body","description":"教育性正文行(每行非空)"},"disclaimer":{"type":"string","minLength":1,"title":"Disclaimer","description":"免责声明(非空)"},"cta":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cta","description":"可选 CTA"}},"additionalProperties":false,"type":"object","required":["language_tag","body","disclaimer"],"title":"FactCardLocalizedCopy","description":"fact card 的单语言文案。"},"FileDetailRead":{"properties":{"id":{"type":"string","title":"Id","description":"文件 ID"},"type":{"$ref":"#/components/schemas/FileTypeEnum","description":"文件类型"},"name":{"type":"string","title":"Name","description":"文件名/标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"},"usages":{"items":{"$ref":"#/components/schemas/FileUsageRead"},"type":"array","title":"Usages"}},"type":"object","required":["id","type","name"],"title":"FileDetailRead","description":"含 file_usages 列表(详情接口)。"},"FileRead":{"properties":{"id":{"type":"string","title":"Id","description":"文件 ID"},"type":{"$ref":"#/components/schemas/FileTypeEnum","description":"文件类型"},"name":{"type":"string","title":"Name","description":"文件名/标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"}},"type":"object","required":["id","type","name"],"title":"FileRead"},"FileTypeEnum":{"type":"string","enum":["image","video","subtitle"],"title":"FileTypeEnum"},"FileUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail"},"tags":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tags"},"usage":{"anyOf":[{"$ref":"#/components/schemas/FileUsageWrite"},{"type":"null"}],"description":"若提供则 upsert 一条 file_usages"}},"type":"object","title":"FileUpdate"},"FileUsageRead":{"properties":{"id":{"type":"integer","title":"Id"},"file_id":{"type":"string","title":"File Id"},"project_id":{"type":"string","title":"Project Id"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"},"usage_kind":{"type":"string","title":"Usage Kind"},"source_ref":{"type":"string","title":"Source Ref"}},"type":"object","required":["id","file_id","project_id","chapter_id","shot_id","usage_kind","source_ref"],"title":"FileUsageRead"},"FileUsageWrite":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID"},"usage_kind":{"type":"string","title":"Usage Kind","description":"用途:shot_frame / generated_video / character_image / asset_image / upload / api 等"},"source_ref":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Ref","description":"幂等键(可选)"}},"type":"object","required":["project_id","usage_kind"],"title":"FileUsageWrite","description":"写入 file_usages 的关联信息(与 FileItem 一并提交)。"},"FrameGuidanceDecisionRead":{"properties":{"text":{"type":"string","title":"Text","description":"guidance 原文"},"category":{"type":"string","title":"Category","description":"guidance 分类,如 summary / continuity / composition / screen"},"reason_tag":{"type":"string","title":"Reason Tag","description":"简短原因标签,如 首帧保空间 / 关键帧保轴线","default":""},"reason":{"type":"string","title":"Reason","description":"该 guidance 被保留或压缩的原因说明"}},"type":"object","required":["text","category","reason"],"title":"FrameGuidanceDecisionRead","description":"分镜帧 guidance 的保留/压缩决策结果。"},"GenerationTaskLinkCreate":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"生成任务 ID"},"resource_type":{"type":"string","title":"Resource Type","description":"生成资源类型(如 image/video/text/task_link)"},"relation_type":{"type":"string","title":"Relation Type","description":"业务类型(如 prop/costume/scene 等)"},"relation_entity_id":{"type":"string","title":"Relation Entity Id","description":"关联业务实体 ID"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联产物文件 ID(files.id;适用于图片/音频/视频)"},"status":{"type":"string","title":"Status","description":"关联状态:accepted=已采用、todo=待操作、rejected=未采用;默认 todo","default":"todo"}},"type":"object","required":["task_id","resource_type","relation_type","relation_entity_id"],"title":"GenerationTaskLinkCreate","description":"创建生成任务关联请求体。"},"GenerationTaskLinkRead":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"生成任务 ID"},"resource_type":{"type":"string","title":"Resource Type","description":"生成资源类型(如 image/video/text/task_link)"},"relation_type":{"type":"string","title":"Relation Type","description":"业务类型(如 prop/costume/scene 等)"},"relation_entity_id":{"type":"string","title":"Relation Entity Id","description":"关联业务实体 ID"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联产物文件 ID(files.id;适用于图片/音频/视频)"},"status":{"type":"string","title":"Status","description":"关联状态:accepted=已采用、todo=待操作、rejected=未采用"},"id":{"type":"integer","title":"Id","description":"关联行 ID"}},"type":"object","required":["task_id","resource_type","relation_type","relation_entity_id","status","id"],"title":"GenerationTaskLinkRead","description":"生成任务关联返回体。"},"GenerationTaskLinkUpdate":{"properties":{"resource_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resource Type","description":"生成资源类型(如 image/video/text/task_link)"},"relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Type","description":"业务类型(如 prop/costume/scene 等)"},"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"关联业务实体 ID"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联产物文件 ID(files.id;适用于图片/音频/视频)"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status","description":"关联状态:accepted=已采用、todo=待操作、rejected=未采用"}},"type":"object","title":"GenerationTaskLinkUpdate","description":"更新生成任务关联请求体(不包含 is_adopted,采用状态由专用接口正向变更)。"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"ImageGenerationOptionsRead":{"properties":{"provider":{"type":"string","title":"Provider","description":"供应商稳定键"},"model_id":{"type":"string","title":"Model Id","description":"默认图片模型 ID"},"model_name":{"type":"string","title":"Model Name","description":"默认图片模型名称"},"supported_ratios":{"items":{"type":"string"},"type":"array","title":"Supported Ratios","description":"当前模型支持的目标比例"},"default_resolution_profile":{"type":"string","title":"Default Resolution Profile","description":"当前模型默认分辨率档位"},"ratio_size_profiles":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object","title":"Ratio Size Profiles","description":"按比例和分辨率档位映射得到的像素尺寸"}},"type":"object","required":["provider","model_id","model_name","default_resolution_profile"],"title":"ImageGenerationOptionsRead","description":"当前默认图片模型对应的关键帧规格选项。"},"ImportCounts":{"properties":{"chapters":{"type":"integer","title":"Chapters","default":0},"shots":{"type":"integer","title":"Shots","default":0},"shot_details":{"type":"integer","title":"Shot Details","default":0},"dialog_lines":{"type":"integer","title":"Dialog Lines","default":0},"characters":{"type":"integer","title":"Characters","default":0},"actors":{"type":"integer","title":"Actors","default":0},"scenes":{"type":"integer","title":"Scenes","default":0},"props":{"type":"integer","title":"Props","default":0},"costumes":{"type":"integer","title":"Costumes","default":0},"links":{"type":"integer","title":"Links","default":0}},"additionalProperties":false,"type":"object","title":"ImportCounts","description":"各类实体的计数(created 或 reused 各一份)。"},"ImportEpisodeRequest":{"properties":{"project_id":{"type":"string","minLength":1,"title":"Project Id","description":"目标 Jellyfish 项目 ID(系列/季)"},"episode_package":{"anyOf":[{"$ref":"#/components/schemas/EpisodePackageV11"},{"$ref":"#/components/schemas/EpisodePackage"}],"title":"Episode Package","description":"待导入的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1)"},"dry_run":{"type":"boolean","title":"Dry Run","description":"为真时只校验/映射/复用查找/告警,不写库","default":false},"idempotency_key":{"type":"string","minLength":1,"title":"Idempotency Key","description":"幂等键"}},"additionalProperties":false,"type":"object","required":["project_id","episode_package","idempotency_key"],"title":"ImportEpisodeRequest","description":"POST /api/v1/crypto-animal-studio/import 的请求体。"},"ImportResult":{"properties":{"status":{"type":"string","title":"Status","description":"imported | dry_run | replayed"},"dry_run":{"type":"boolean","title":"Dry Run","description":"是否为 dry-run(未写库)"},"idempotent_replay":{"type":"boolean","title":"Idempotent Replay","description":"是否命中幂等重放(返回既有结果)"},"project_id":{"type":"string","title":"Project Id"},"episode_id":{"type":"string","title":"Episode Id"},"idempotency_key":{"type":"string","title":"Idempotency Key"},"payload_hash":{"type":"string","title":"Payload Hash","description":"EpisodePackage 规范化 SHA-256"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"产生/既有的 Chapter ID;dry-run 为 null"},"chapter_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Chapter Index","description":"Chapter 在项目内的序号;dry-run 为拟用序号"},"created":{"$ref":"#/components/schemas/ImportCounts","description":"本次新建计数"},"reused":{"$ref":"#/components/schemas/ImportCounts","description":"本次复用计数"},"warnings":{"items":{"type":"string"},"type":"array","title":"Warnings","description":"非阻断告警(不丢弃数据)"},"subtitle_artifacts":{"items":{"$ref":"#/components/schemas/SubtitleArtifact"},"type":"array","title":"Subtitle Artifacts","description":"本次导入生成/复用的字幕产物(WebVTT);v1 文档为空列表"}},"additionalProperties":false,"type":"object","required":["status","dry_run","idempotent_replay","project_id","episode_id","idempotency_key","payload_hash"],"title":"ImportResult","description":"一次导入(或 dry-run / 重放)的结果摘要。"},"Localization":{"properties":{"spoken_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Spoken Language","description":"对白语言(缺省回落到根 language)"},"required_publish_language_tags":{"items":{"type":"string"},"type":"array","title":"Required Publish Language Tags","description":"发布前必须具备字幕的语言标签;空表示无要求"},"subtitle_tracks":{"items":{"$ref":"#/components/schemas/SubtitleTrack"},"type":"array","title":"Subtitle Tracks","description":"字幕轨列表"}},"additionalProperties":false,"type":"object","title":"Localization","description":"口语与字幕本地化。字幕结构上可选;必需语言只来自 required_publish_language_tags。"},"LogLevel":{"type":"string","enum":["debug","info","warn","error"],"title":"LogLevel","description":"全局日志级别。"},"MarketData":{"properties":{"instrument":{"type":"string","minLength":1,"title":"Instrument","description":"标的,如 BTC-USD"},"timeframe":{"type":"string","minLength":1,"title":"Timeframe","description":"确认所用周期,如 4h"},"resistance_level":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resistance Level","description":"被突破的阻力位"},"price":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price","description":"事件时价格"},"price_move_pct":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price Move Pct","description":"区间涨跌幅"},"pullback_pct":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pullback Pct","description":"回撤幅度"},"event_timestamp_utc":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Event Timestamp Utc","description":"事件时间"},"candle_close_timestamp_utc":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Candle Close Timestamp Utc","description":"确认K棒收盘时间"},"as_of_utc":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"As Of Utc","description":"数据 as-of 时间"},"source_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Name","description":"数据来源名称"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url","description":"公开溯源 URL(仅证据,非执行端点)"},"factual_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Factual Note","description":"人工核对备注"},"ath_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ath Context","description":"可选前高背景"},"data_lock":{"$ref":"#/components/schemas/DataLock","description":"锁定状态"}},"additionalProperties":false,"type":"object","required":["instrument","timeframe"],"title":"MarketData","description":"市场事实溯源。数值刻意为「可含占位符的字符串」(最小化 v1.1 折衷)。"},"ModelCategoryKey":{"type":"string","enum":["text","image","video"],"title":"ModelCategoryKey","description":"模型类别:文本/图片/视频。"},"ModelCreate":{"properties":{"name":{"type":"string","title":"Name","description":"模型名称"},"category":{"$ref":"#/components/schemas/ModelCategoryKey","description":"模型类别:text/image/video"},"provider_id":{"type":"string","title":"Provider Id","description":"所属供应商 ID"},"params":{"additionalProperties":true,"type":"object","title":"Params","description":"模型参数(JSON)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"模型 ID"}},"type":"object","required":["name","category","provider_id","id"],"title":"ModelCreate","description":"创建模型请求体。"},"ModelRead":{"properties":{"name":{"type":"string","title":"Name","description":"模型名称"},"category":{"$ref":"#/components/schemas/ModelCategoryKey","description":"模型类别:text/image/video"},"provider_id":{"type":"string","title":"Provider Id","description":"所属供应商 ID"},"params":{"additionalProperties":true,"type":"object","title":"Params","description":"模型参数(JSON)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"模型 ID"}},"type":"object","required":["name","category","provider_id","id"],"title":"ModelRead","description":"对外返回的模型信息。"},"ModelSettingsRead":{"properties":{"default_text_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Text Model Id","description":"默认文本模型 ID"},"default_image_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Image Model Id","description":"默认图片模型 ID"},"default_video_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Model Id","description":"默认视频模型 ID"},"api_timeout":{"type":"integer","title":"Api Timeout","description":"API 超时(秒)","default":30},"log_level":{"$ref":"#/components/schemas/LogLevel","description":"日志级别","default":"info"},"id":{"type":"integer","title":"Id","description":"设置行 ID(通常为 1)"}},"type":"object","required":["id"],"title":"ModelSettingsRead","description":"对外返回的模型全局设置。"},"ModelSettingsUpdate":{"properties":{"default_text_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Text Model Id","description":"默认文本模型 ID"},"default_image_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Image Model Id","description":"默认图片模型 ID"},"default_video_model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Model Id","description":"默认视频模型 ID"},"api_timeout":{"type":"integer","title":"Api Timeout","description":"API 超时(秒)","default":30},"log_level":{"$ref":"#/components/schemas/LogLevel","description":"日志级别","default":"info"}},"type":"object","title":"ModelSettingsUpdate","description":"更新或保存模型全局设置请求体。"},"ModelUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"模型名称"},"category":{"anyOf":[{"$ref":"#/components/schemas/ModelCategoryKey"},{"type":"null"}],"description":"模型类别"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id","description":"所属供应商 ID"},"params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Params","description":"模型参数(JSON)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"说明"}},"type":"object","title":"ModelUpdate","description":"更新模型请求体(全部可选)。"},"NewsSource":{"properties":{"source_type":{"type":"string","enum":["news","original","fictional","generic"],"title":"Source Type","description":"来源类型:news/original/fictional/generic"},"headline":{"type":"string","title":"Headline","description":"标题(新闻标题或原创触发点标题)","default":""},"summary":{"type":"string","title":"Summary","description":"摘要:事件的中性概述","default":""},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url","description":"来源链接(可选;原创内容可为空)"},"published_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Published At","description":"发布时间(ISO-8601 字符串,可选)"},"factual_notes":{"type":"string","title":"Factual Notes","description":"事实性备注:不得改写为投资建议或价格预测","default":""}},"additionalProperties":false,"type":"object","required":["source_type"],"title":"NewsSource","description":"一集的素材来源:新闻或原创设定的事实性上下文。\n\n仅承载「事实/触发点」,不含创意执行;便于追溯与审核。"},"OutputSpec":{"properties":{"aspect_ratio":{"type":"string","title":"Aspect Ratio","description":"画面比例,形如 W:H","default":"9:16"},"width":{"type":"integer","exclusiveMinimum":0.0,"title":"Width","description":"渲染宽度(像素)","default":1080},"height":{"type":"integer","exclusiveMinimum":0.0,"title":"Height","description":"渲染高度(像素)","default":1920},"fps":{"type":"integer","exclusiveMinimum":0.0,"title":"Fps","description":"帧率","default":30},"orientation":{"type":"string","enum":["vertical","horizontal","square"],"title":"Orientation","description":"画面方向","default":"vertical"},"generated_footage_ms":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Generated Footage Ms","description":"生成footage总毫秒(可选断言)"},"total_runtime_ms":{"anyOf":[{"type":"integer","exclusiveMinimum":0.0},{"type":"null"}],"title":"Total Runtime Ms","description":"最终成片总毫秒(可选断言)"},"safe_area":{"$ref":"#/components/schemas/SafeArea","description":"安全区元数据"}},"additionalProperties":false,"type":"object","title":"OutputSpec","description":"输出规格;``*_ms`` 断言永不覆盖派生时长。"},"OverlayLocalizedText":{"properties":{"language_tag":{"type":"string","minLength":1,"title":"Language Tag","description":"BCP 47 语言标签"},"text":{"type":"string","title":"Text","description":"文案"}},"additionalProperties":false,"type":"object","required":["language_tag","text"],"title":"OverlayLocalizedText","description":"叠加图形的单语言文案。"},"PaginatedData_Any_":{"properties":{"items":{"items":{},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[Any]"},"PaginatedData_ChapterRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ChapterRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ChapterRead]"},"PaginatedData_FileRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/FileRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[FileRead]"},"PaginatedData_GenerationTaskLinkRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/GenerationTaskLinkRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[GenerationTaskLinkRead]"},"PaginatedData_ModelRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ModelRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ModelRead]"},"PaginatedData_ProjectRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ProjectRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ProjectRead]"},"PaginatedData_PromptTemplateRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/PromptTemplateRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[PromptTemplateRead]"},"PaginatedData_ProviderRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ProviderRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ProviderRead]"},"PaginatedData_ShotDetailRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotDetailRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotDetailRead]"},"PaginatedData_ShotDialogLineRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotDialogLineRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotDialogLineRead]"},"PaginatedData_ShotFrameImageRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotFrameImageRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotFrameImageRead]"},"PaginatedData_ShotLinkedAssetItem_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotLinkedAssetItem"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotLinkedAssetItem]"},"PaginatedData_ShotRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShotRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[ShotRead]"},"PaginatedData_TaskListItemRead_":{"properties":{"items":{"items":{"$ref":"#/components/schemas/TaskListItemRead"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[TaskListItemRead]"},"PaginatedData_dict_str__Any__":{"properties":{"items":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Items","description":"当前页数据"},"pagination":{"$ref":"#/components/schemas/Pagination","description":"分页信息"}},"type":"object","required":["items","pagination"],"title":"PaginatedData[dict[str, Any]]"},"Pagination":{"properties":{"page":{"type":"integer","title":"Page","description":"当前页,从 1 开始"},"page_size":{"type":"integer","title":"Page Size","description":"每页条数"},"total":{"type":"integer","title":"Total","description":"总条数"},"max_page":{"type":"integer","title":"Max Page","description":"最大页码"}},"type":"object","required":["page","page_size","total","max_page"],"title":"Pagination","description":"分页信息。"},"PostProduction":{"properties":{"overlays":{"items":{"$ref":"#/components/schemas/PostProductionOverlay"},"type":"array","title":"Overlays","description":"叠加列表"}},"additionalProperties":false,"type":"object","title":"PostProduction","description":"后期叠加计划:所有可读金融文字都在这里,不进入生成画面。"},"PostProductionOverlay":{"properties":{"overlay_id":{"type":"string","minLength":1,"title":"Overlay Id","description":"稳定 ID(被 shots[].overlay_ids 引用)"},"type":{"type":"string","enum":["chart_label","subtitle","notification","fact_card","disclaimer","cta","other"],"title":"Type","description":"叠加类型"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"关联镜头(null 表示 episode 级)"},"start_ms":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Start Ms","description":"入点(episode-absolute 毫秒)"},"end_ms":{"anyOf":[{"type":"integer","exclusiveMinimum":0.0},{"type":"null"}],"title":"End Ms","description":"出点(episode-absolute 毫秒)"},"required":{"type":"boolean","title":"Required","description":"是否必需(可选叠加允许省略)","default":true},"anchor":{"type":"string","enum":["lower_safe","upper_safe","centre","prop_local"],"title":"Anchor","description":"安全区锚点","default":"lower_safe"},"localized":{"items":{"$ref":"#/components/schemas/OverlayLocalizedText"},"type":"array","title":"Localized","description":"各语言文案"}},"additionalProperties":false,"type":"object","required":["overlay_id","type"],"title":"PostProductionOverlay","description":"后期叠加图形;时间为 episode-absolute 毫秒,shot_id 仅作关联。"},"ProductionArtifactView":{"properties":{"id":{"type":"string","title":"Id"},"production_shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Production Shot Id"},"artifact_type":{"type":"string","title":"Artifact Type"},"stage":{"type":"string","title":"Stage"},"provider":{"type":"string","title":"Provider"},"provider_model":{"type":"string","title":"Provider Model"},"file_path":{"type":"string","title":"File Path"},"mime_type":{"type":"string","title":"Mime Type"},"checksum":{"type":"string","title":"Checksum"}},"additionalProperties":false,"type":"object","required":["id","production_shot_id","artifact_type","stage","provider","provider_model","file_path","mime_type","checksum"],"title":"ProductionArtifactView","description":"产物视图。"},"ProductionJobView":{"properties":{"id":{"type":"string","title":"Id"},"project_id":{"type":"string","title":"Project Id"},"episode_id":{"type":"string","title":"Episode Id"},"status":{"type":"string","title":"Status"},"current_stage":{"type":"string","title":"Current Stage"},"provider_mode":{"type":"string","title":"Provider Mode"},"episode_package_hash":{"type":"string","title":"Episode Package Hash"},"output_path":{"type":"string","title":"Output Path"},"error_message":{"type":"string","title":"Error Message"},"started_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Completed At"},"shots":{"items":{"$ref":"#/components/schemas/ProductionShotView"},"type":"array","title":"Shots"},"manifest_path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manifest Path"},"final_output":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Final Output"}},"additionalProperties":false,"type":"object","required":["id","project_id","episode_id","status","current_stage","provider_mode","episode_package_hash","output_path","error_message"],"title":"ProductionJobView","description":"生产任务视图。"},"ProductionShotView":{"properties":{"id":{"type":"string","title":"Id"},"source_shot_id":{"type":"string","title":"Source Shot Id"},"sequence":{"type":"integer","title":"Sequence"},"status":{"type":"string","title":"Status"},"current_stage":{"type":"string","title":"Current Stage"},"duration_seconds":{"type":"number","title":"Duration Seconds"},"error_message":{"type":"string","title":"Error Message"}},"additionalProperties":false,"type":"object","required":["id","source_shot_id","sequence","status","current_stage","duration_seconds","error_message"],"title":"ProductionShotView","description":"生产镜头视图。"},"ProjectActorLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"actor_id":{"type":"string","title":"Actor Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"演员缩略图下载地址","default":""}},"type":"object","required":["id","project_id","actor_id"],"title":"ProjectActorLinkRead"},"ProjectAssetLinkCreate":{"properties":{"project_id":{"type":"string","title":"Project Id"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id"},"asset_id":{"type":"string","title":"Asset Id"}},"type":"object","required":["project_id","asset_id"],"title":"ProjectAssetLinkCreate"},"ProjectCostumeLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"costume_id":{"type":"string","title":"Costume Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"服装缩略图下载地址","default":""}},"type":"object","required":["id","project_id","costume_id"],"title":"ProjectCostumeLinkRead"},"ProjectCreate":{"properties":{"name":{"type":"string","title":"Name","description":"项目名称"},"description":{"type":"string","title":"Description","description":"项目简介","default":""},"style":{"$ref":"#/components/schemas/ProjectStyle","description":"题材/风格","examples":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"]},"visual_style":{"$ref":"#/components/schemas/ProjectVisualStyle","description":"画面表现形式","default":"现实"},"seed":{"type":"integer","title":"Seed","description":"随机种子","default":0},"unify_style":{"type":"boolean","title":"Unify Style","description":"是否统一风格","default":true},"progress":{"type":"integer","title":"Progress","description":"进度百分比(0-100)","default":0},"default_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Ratio","description":"项目级默认视频比例;分镜未覆盖时生效"},"stats":{"additionalProperties":true,"type":"object","title":"Stats","description":"聚合统计(JSON)"},"id":{"type":"string","title":"Id","description":"项目 ID"}},"type":"object","required":["name","style","id"],"title":"ProjectCreate"},"ProjectPropLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"prop_id":{"type":"string","title":"Prop Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"道具缩略图下载地址","default":""}},"type":"object","required":["id","project_id","prop_id"],"title":"ProjectPropLinkRead"},"ProjectRead":{"properties":{"name":{"type":"string","title":"Name","description":"项目名称"},"description":{"type":"string","title":"Description","description":"项目简介","default":""},"style":{"$ref":"#/components/schemas/ProjectStyle","description":"题材/风格","examples":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"]},"visual_style":{"$ref":"#/components/schemas/ProjectVisualStyle","description":"画面表现形式","default":"现实"},"seed":{"type":"integer","title":"Seed","description":"随机种子","default":0},"unify_style":{"type":"boolean","title":"Unify Style","description":"是否统一风格","default":true},"progress":{"type":"integer","title":"Progress","description":"进度百分比(0-100)","default":0},"default_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Ratio","description":"项目级默认视频比例;分镜未覆盖时生效"},"stats":{"additionalProperties":true,"type":"object","title":"Stats","description":"聚合统计(JSON)"},"id":{"type":"string","title":"Id"}},"type":"object","required":["name","style","id"],"title":"ProjectRead"},"ProjectSceneLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(可空)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"镜头 ID(可空)"},"scene_id":{"type":"string","title":"Scene Id"},"thumbnail":{"type":"string","title":"Thumbnail","description":"场景缩略图下载地址","default":""}},"type":"object","required":["id","project_id","scene_id"],"title":"ProjectSceneLinkRead"},"ProjectStyle":{"type":"string","enum":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"],"title":"ProjectStyle","description":"项目题材/风格维度(不用于区分真人/动漫)。"},"ProjectStyleOptionsRead":{"properties":{"visual_styles":{"items":{"$ref":"#/components/schemas/StyleOption"},"type":"array","title":"Visual Styles","description":"视觉风格可选项"},"styles_by_visual_style":{"additionalProperties":{"items":{"$ref":"#/components/schemas/StyleOption"},"type":"array"},"type":"object","title":"Styles By Visual Style","description":"按视觉风格分组的视频风格选项"},"default_style_by_visual_style":{"additionalProperties":{"type":"string"},"type":"object","title":"Default Style By Visual Style","description":"各视觉风格默认视频风格"}},"type":"object","title":"ProjectStyleOptionsRead","description":"项目风格候选项。"},"ProjectUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"style":{"anyOf":[{"$ref":"#/components/schemas/ProjectStyle"},{"type":"null"}],"description":"题材/风格","examples":["真人都市","真人科幻","真人古装","动漫科幻","动漫3D","国漫","水墨画"]},"visual_style":{"anyOf":[{"$ref":"#/components/schemas/ProjectVisualStyle"},{"type":"null"}]},"seed":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Seed"},"unify_style":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Unify Style"},"progress":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Progress"},"default_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Video Ratio"},"stats":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Stats"}},"type":"object","title":"ProjectUpdate"},"ProjectVisualStyle":{"type":"string","enum":["现实","动漫"],"title":"ProjectVisualStyle","description":"画面表现形式维度:用于区分现实/动漫等。"},"PromptCategory":{"type":"string","enum":["frame_head_image","frame_tail_image","frame_key_image","frame_head_prompt","frame_tail_prompt","frame_key_prompt","video_prompt","storyboard_prompt","bgm","sfx","character_image_front","character_image_other","actor_image_front","actor_image_other","prop_image_front","prop_image_other","scene_image_front","scene_image_other","costume_image_front","costume_image_other","combined"],"title":"PromptCategory","description":"提示词模板类别。"},"PromptCategoryOptionRead":{"properties":{"value":{"$ref":"#/components/schemas/PromptCategory","description":"类别枚举值"},"label":{"type":"string","title":"Label","description":"中文名称"},"description":{"type":"string","title":"Description","description":"类别简介","default":""}},"type":"object","required":["value","label"],"title":"PromptCategoryOptionRead","description":"提示词类别选项(枚举值 + 中文标签 + 简介)。"},"PromptTemplateCreate":{"properties":{"category":{"$ref":"#/components/schemas/PromptCategory","description":"模板类别"},"name":{"type":"string","title":"Name","description":"模板名称"},"content":{"type":"string","title":"Content","description":"模板内容"},"preview":{"type":"string","title":"Preview","description":"预览文案","default":""},"variables":{"items":{"type":"string"},"type":"array","title":"Variables","description":"变量名列表"},"is_default":{"type":"boolean","title":"Is Default","description":"是否为默认提示词","default":false}},"type":"object","required":["category","name","content"],"title":"PromptTemplateCreate","description":"创建提示词模板。id 由后端自动生成;is_system 不可由客户端设置。"},"PromptTemplateRead":{"properties":{"id":{"type":"string","title":"Id","description":"模板 ID"},"category":{"$ref":"#/components/schemas/PromptCategory","description":"模板类别"},"name":{"type":"string","title":"Name","description":"模板名称"},"preview":{"type":"string","title":"Preview","description":"预览文案"},"content":{"type":"string","title":"Content","description":"模板内容"},"variables":{"items":{"type":"string"},"type":"array","title":"Variables","description":"变量名列表"},"is_default":{"type":"boolean","title":"Is Default","description":"是否为默认提示词"},"is_system":{"type":"boolean","title":"Is System","description":"是否为系统预置"},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"创建时间"},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"最后更新时间"}},"type":"object","required":["id","category","name","preview","content","variables","is_default","is_system","created_at","updated_at"],"title":"PromptTemplateRead","description":"读取提示词模板(含全部字段)。"},"PromptTemplateUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"模板名称"},"content":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content","description":"模板内容"},"preview":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview","description":"预览文案"},"variables":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Variables","description":"变量名列表(整体替换)"},"is_default":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Default","description":"是否为默认提示词"}},"type":"object","title":"PromptTemplateUpdate","description":"局部更新提示词模板。不含 id / is_system。"},"PropAsset":{"properties":{"prop_key":{"type":"string","minLength":1,"title":"Prop Key","description":"道具键(素材类别内唯一)"},"display_name":{"type":"string","title":"Display Name","description":"展示名","default":""},"description":{"type":"string","title":"Description","description":"道具描述","default":""}},"additionalProperties":false,"type":"object","required":["prop_key"],"title":"PropAsset","description":"道具素材。"},"PropInfoAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"prop_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prop Context","description":"原文道具上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"prop_description":{"type":"string","minLength":1,"title":"Prop Description","description":"原文道具描述"}},"type":"object","required":["prop_description"],"title":"PropInfoAnalysisRequest","description":"道具信息缺失分析请求。"},"PropInfoAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"PropInfoAnalysisResult","description":"根据原文道具描述,分析缺少的信息,并给出优化后的可生成道具描述。"},"ProviderCreate":{"properties":{"name":{"type":"string","title":"Name","description":"供应商名称"},"base_url":{"type":"string","title":"Base Url","description":"文本/通用 API Base URL"},"image_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Base Url","description":"图片能力 API Base URL(可选覆盖)"},"video_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Base Url","description":"视频能力 API Base URL(可选覆盖)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"status":{"$ref":"#/components/schemas/ProviderStatus","description":"状态:active/testing/disabled","default":"testing"},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"供应商 ID"},"api_key":{"type":"string","title":"Api Key","description":"API Key(敏感,不在响应中回显)","default":""},"api_secret":{"type":"string","title":"Api Secret","description":"API Secret(敏感,不在响应中回显)","default":""}},"type":"object","required":["name","base_url","id"],"title":"ProviderCreate","description":"创建供应商时的请求体,允许填写敏感字段。"},"ProviderRead":{"properties":{"name":{"type":"string","title":"Name","description":"供应商名称"},"base_url":{"type":"string","title":"Base Url","description":"文本/通用 API Base URL"},"image_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Base Url","description":"图片能力 API Base URL(可选覆盖)"},"video_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Base Url","description":"视频能力 API Base URL(可选覆盖)"},"description":{"type":"string","title":"Description","description":"说明","default":""},"status":{"$ref":"#/components/schemas/ProviderStatus","description":"状态:active/testing/disabled","default":"testing"},"created_by":{"type":"string","title":"Created By","description":"创建人","default":""},"id":{"type":"string","title":"Id","description":"供应商 ID"}},"type":"object","required":["name","base_url","id"],"title":"ProviderRead","description":"对外返回的供应商信息(不包含 api_key/api_secret)。"},"ProviderStatus":{"type":"string","enum":["active","testing","disabled"],"title":"ProviderStatus","description":"供应商启用状态。"},"ProviderSupportedRead":{"properties":{"key":{"type":"string","title":"Key","description":"供应商稳定键"},"display_name":{"type":"string","title":"Display Name","description":"供应商展示名"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases","description":"可识别别名"},"supported_categories":{"items":{"$ref":"#/components/schemas/ModelCategoryKey"},"type":"array","title":"Supported Categories","description":"支持的模型类别"},"default_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Base Url","description":"默认 API Base URL"},"requires_api_key":{"type":"boolean","title":"Requires Api Key","description":"是否要求 api_key","default":true},"requires_api_secret":{"type":"boolean","title":"Requires Api Secret","description":"是否要求 api_secret","default":false},"is_experimental":{"type":"boolean","title":"Is Experimental","description":"是否实验性供应商","default":false}},"type":"object","required":["key","display_name"],"title":"ProviderSupportedRead","description":"系统支持的供应商能力清单。"},"ProviderUpdate":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name","description":"供应商名称"},"base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Base Url","description":"文本/通用 API Base URL"},"image_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Image Base Url","description":"图片能力 API Base URL(可选覆盖)"},"video_base_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Video Base Url","description":"视频能力 API Base URL(可选覆盖)"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"说明"},"status":{"anyOf":[{"$ref":"#/components/schemas/ProviderStatus"},{"type":"null"}],"description":"状态:active/testing/disabled"},"api_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Key","description":"API Key(敏感,不在响应中回显)"},"api_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Api Secret","description":"API Secret(敏感,不在响应中回显)"}},"type":"object","title":"ProviderUpdate","description":"更新供应商时的可选字段。"},"ReferenceAsset":{"properties":{"character_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Character Key","description":"角色键(角色参考用)"},"scene_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Key","description":"场景键(环境参考用)"},"prop_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prop Key","description":"道具键(道具参考用)"},"asset_id":{"type":"string","minLength":1,"title":"Asset Id","description":"稳定不透明资产 ID"},"kind":{"type":"string","enum":["identity","episode"],"title":"Kind","description":"不可变身份参考 vs 本集专用","default":"identity"},"view":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"View","description":"视角提示,如 front"},"path":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Path","description":"仓库相对路径;**不得**为供应商 URL"}},"additionalProperties":false,"type":"object","required":["asset_id"],"title":"ReferenceAsset","description":"一条参考资产:稳定 asset_id + 可选仓库相对路径(禁止供应商 URL)。"},"References":{"properties":{"bible_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bible Version","description":"Bible 版本,如 1.0"},"canon_decision":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Canon Decision","description":"治理决策,如 ADR-015"},"characters":{"items":{"$ref":"#/components/schemas/ReferenceAsset"},"type":"array","title":"Characters","description":"角色参考"},"environments":{"items":{"$ref":"#/components/schemas/ReferenceAsset"},"type":"array","title":"Environments","description":"环境参考"},"props":{"items":{"$ref":"#/components/schemas/ReferenceAsset"},"type":"array","title":"Props","description":"道具参考"}},"additionalProperties":false,"type":"object","title":"References","description":"Bible 版本与参考资产集合。"},"RegenerationFallback":{"properties":{"camera_movement":{"anyOf":[{"$ref":"#/components/schemas/CasCameraMovement"},{"type":"null"}],"description":"兜底运镜(复用既有枚举)"},"note":{"type":"string","title":"Note","description":"适用条件说明","default":""}},"additionalProperties":false,"type":"object","title":"RegenerationFallback","description":"重生成兜底(仅恢复手段,不是同等生产选项)。"},"RenderedPromptResponse":{"properties":{"prompt":{"type":"string","title":"Prompt","description":"渲染后的提示词(已套用模板与变量替换)"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"参考图 file_id 列表(自动选择;顺序有效)"}},"type":"object","required":["prompt"],"title":"RenderedPromptResponse"},"RenderedShotFramePromptRead":{"properties":{"base_prompt":{"type":"string","title":"Base Prompt","description":"原始基础提示词(不含图片映射说明)"},"rendered_prompt":{"type":"string","title":"Rendered Prompt","description":"最终提交给模型的提示词(含图片映射说明)"},"selected_guidance":{"items":{"type":"string"},"type":"array","title":"Selected Guidance","description":"最终 prompt 实际保留的 guidance 列表"},"dropped_guidance":{"items":{"type":"string"},"type":"array","title":"Dropped Guidance","description":"本次渲染中被压缩掉的 guidance 列表"},"selected_guidance_details":{"items":{"$ref":"#/components/schemas/FrameGuidanceDecisionRead"},"type":"array","title":"Selected Guidance Details","description":"最终保留 guidance 的决策详情"},"dropped_guidance_details":{"items":{"$ref":"#/components/schemas/FrameGuidanceDecisionRead"},"type":"array","title":"Dropped Guidance Details","description":"被压缩 guidance 的决策详情"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"最终参考图 file_id 列表,顺序与 mappings 一致"},"mappings":{"items":{"$ref":"#/components/schemas/ShotFramePromptMappingRead"},"type":"array","title":"Mappings","description":"图片与实体名称的映射关系,顺序与 images 完全一致"}},"type":"object","required":["base_prompt","rendered_prompt"],"title":"RenderedShotFramePromptRead","description":"关键帧最终生成提示词渲染结果。"},"RetryProductionJobRequest":{"properties":{"episode_package":{"anyOf":[{"$ref":"#/components/schemas/EpisodePackageV11"},{"$ref":"#/components/schemas/EpisodePackage"}],"title":"Episode Package","description":"与原任务一致的 EpisodePackage(用于重跑;接受 schema_version 1.0 或 1.1)"},"mode":{"type":"string","const":"mock","title":"Mode","description":"供应商模式;本冲刺仅支持 mock","default":"mock"}},"additionalProperties":false,"type":"object","required":["episode_package"],"title":"RetryProductionJobRequest","description":"POST /production/jobs/{job_id}/retry 请求体。"},"SafeArea":{"properties":{"subtitle_bottom_pct":{"type":"number","maximum":50.0,"minimum":0.0,"title":"Subtitle Bottom Pct","description":"字幕安全带(画面底部百分比)","default":18},"margin_pct":{"type":"number","maximum":25.0,"minimum":0.0,"title":"Margin Pct","description":"通用安全边距(百分比)","default":6}},"additionalProperties":false,"type":"object","title":"SafeArea","description":"安全区元数据(百分比)。"},"SceneAsset":{"properties":{"scene_key":{"type":"string","minLength":1,"title":"Scene Key","description":"场景键(素材类别内唯一)"},"display_name":{"type":"string","title":"Display Name","description":"展示名","default":""},"description":{"type":"string","title":"Description","description":"场景描述","default":""}},"additionalProperties":false,"type":"object","required":["scene_key"],"title":"SceneAsset","description":"场景素材。"},"SceneInfoAnalysisRequest":{"properties":{"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"任务关联实体 ID(资产页恢复任务可选)"},"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"scene_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Context","description":"原文场景上下文(可为空;用于提供额外背景,帮助判断缺失信息)"},"scene_description":{"type":"string","minLength":1,"title":"Scene Description","description":"原文场景描述"}},"type":"object","required":["scene_description"],"title":"SceneInfoAnalysisRequest","description":"场景信息缺失分析请求。"},"SceneInfoAnalysisResult":{"properties":{"issues":{"items":{"type":"string"},"type":"array","title":"Issues"},"optimized_description":{"type":"string","title":"Optimized Description"}},"additionalProperties":false,"type":"object","required":["issues","optimized_description"],"title":"SceneInfoAnalysisResult","description":"根据原文场景描述,分析缺少的信息,并给出优化后的可生成场景描述。"},"ScriptConsistencyCheckRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"完整剧本文本"}},"type":"object","required":["script_text"],"title":"ScriptConsistencyCheckRequest","description":"一致性检查请求(角色混淆)。"},"ScriptConsistencyCheckResult":{"properties":{"issues":{"items":{"$ref":"#/components/schemas/ScriptConsistencyIssue"},"type":"array","title":"Issues","description":"问题列表"},"has_issues":{"type":"boolean","title":"Has Issues","description":"是否发现问题"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary","description":"总结(可选)"}},"additionalProperties":false,"type":"object","required":["has_issues"],"title":"ScriptConsistencyCheckResult","description":"基于原文的一致性检查结果(聚焦角色混淆)。"},"ScriptConsistencyIssue":{"properties":{"issue_type":{"type":"string","const":"character_confusion","title":"Issue Type","description":"固定为角色混淆类问题","default":"character_confusion"},"character_candidates":{"items":{"type":"string"},"type":"array","title":"Character Candidates","description":"涉及的角色候选(名字/称呼/ID 皆可,优先用原文称呼)"},"description":{"type":"string","title":"Description","description":"问题描述(为什么会混淆)"},"suggestion":{"type":"string","title":"Suggestion","description":"修改建议(如何改写以消除混淆)"},"affected_lines":{"anyOf":[{"additionalProperties":{"type":"integer"},"type":"object"},{"type":"null"}],"title":"Affected Lines","description":"受影响的行号范围,形如 {start_line: x, end_line: y}"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["description","suggestion"],"title":"ScriptConsistencyIssue","description":"角色混淆类一致性问题:同一角色在不同镜头被赋予不同身份/行为主体导致混淆。"},"ScriptDividerRequest":{"properties":{"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"完整剧本文本"},"write_to_db":{"type":"boolean","title":"Write To Db","description":"是否将分镜写入数据库(AI Studio shots 表)","default":false},"chapter_id":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(write_to_db=true 时必填)"}},"type":"object","required":["script_text"],"title":"ScriptDividerRequest","description":"剧本分镜请求。"},"ScriptDivisionResult":{"properties":{"shots":{"items":{"$ref":"#/components/schemas/ShotDivision"},"type":"array","title":"Shots","description":"分镜列表"},"total_shots":{"type":"integer","minimum":0.0,"title":"Total Shots","description":"总镜头数"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"拆分说明或建议(可选)"}},"additionalProperties":false,"type":"object","required":["total_shots"],"title":"ScriptDivisionResult","description":"剧本分镜结果:镜头列表(每镜起止行号+预览文本)。"},"ScriptExtractRequest":{"properties":{"project_id":{"type":"string","minLength":1,"title":"Project Id","description":"项目 ID"},"chapter_id":{"type":"string","minLength":1,"title":"Chapter Id","description":"章节 ID"},"script_division":{"additionalProperties":true,"type":"object","title":"Script Division","description":"分镜结果(ScriptDivisionResult 序列化)"},"consistency":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Consistency","description":"一致性检查结果(可选;ScriptConsistencyCheckResult 序列化)"},"refresh_cache":{"type":"boolean","title":"Refresh Cache","description":"是否跳过后端缓存并强制重新提取","default":false}},"type":"object","required":["project_id","chapter_id","script_division"],"title":"ScriptExtractRequest","description":"项目级信息提取请求(最终输出)。"},"ScriptOptimizationResult":{"properties":{"optimized_script_text":{"type":"string","title":"Optimized Script Text","description":"优化后的剧本文本"},"change_summary":{"type":"string","title":"Change Summary","description":"改动摘要(只围绕 issues)"}},"additionalProperties":false,"type":"object","required":["optimized_script_text","change_summary"],"title":"ScriptOptimizationResult","description":"剧本优化输出:仅在发现角色混淆问题时使用。"},"ScriptOptimizeRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"原文剧本文本"},"consistency":{"additionalProperties":true,"type":"object","title":"Consistency","description":"一致性检查输出(ScriptConsistencyCheckResult 序列化)"}},"type":"object","required":["script_text","consistency"],"title":"ScriptOptimizeRequest","description":"剧本优化请求(基于一致性检查结果)。"},"ScriptSimplificationResult":{"properties":{"simplified_script_text":{"type":"string","title":"Simplified Script Text","description":"精简后的剧本文本"},"simplification_summary":{"type":"string","title":"Simplification Summary","description":"精简策略摘要(说明删改原则)"}},"additionalProperties":false,"type":"object","required":["simplified_script_text","simplification_summary"],"title":"ScriptSimplificationResult","description":"剧本精简输出:在保留剧情主体与连续性的前提下压缩篇幅。"},"ScriptSimplifyRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"script_text":{"type":"string","minLength":1,"title":"Script Text","description":"原文剧本文本"}},"type":"object","required":["script_text"],"title":"ScriptSimplifyRequest","description":"智能精简剧本请求。"},"Shot":{"properties":{"shot_id":{"type":"string","minLength":1,"title":"Shot Id","description":"镜头 ID(本集内唯一,非空)"},"sequence":{"type":"integer","exclusiveMinimum":0.0,"title":"Sequence","description":"镜头顺序(正整数,本集内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题/分镜名","default":""},"duration_seconds":{"type":"number","exclusiveMinimum":0.0,"title":"Duration Seconds","description":"镜头时长(秒),必须大于零"},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"镜头对应的剧本摘录","default":""},"camera":{"anyOf":[{"$ref":"#/components/schemas/CameraSpec"},{"type":"null"}],"description":"结构化相机描述(景别/角度/运镜,可选)"},"action":{"type":"string","title":"Action","description":"镜头内动作/视觉描述","default":""},"dialogue":{"items":{"$ref":"#/components/schemas/DialogueLine"},"type":"array","title":"Dialogue","description":"镜头内对白列表"},"character_keys":{"items":{"type":"string"},"type":"array","title":"Character Keys","description":"出场角色键(须存在于 characters)"},"scene_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Key","description":"场景键(可选;提供则须存在于 assets.scenes)"},"prop_keys":{"items":{"type":"string"},"type":"array","title":"Prop Keys","description":"道具键(须存在于 assets.props)"},"costume_keys":{"items":{"type":"string"},"type":"array","title":"Costume Keys","description":"服装键(须存在于 assets.costumes)"},"image_prompt":{"type":"string","title":"Image Prompt","description":"图像生成提示词","default":""},"video_prompt":{"type":"string","title":"Video Prompt","description":"视频生成提示词","default":""},"negative_prompt":{"type":"string","title":"Negative Prompt","description":"反向提示词","default":""},"continuity_notes":{"type":"string","title":"Continuity Notes","description":"镜头连续性备注","default":""},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","description":"镜头级附加元信息"}},"additionalProperties":false,"type":"object","required":["shot_id","sequence","duration_seconds"],"title":"Shot","description":"一个镜头(storyboard 中的 shot),直接映射为 Jellyfish 的 Shot/ShotDetail。\n\n说明:\n- ``camera`` 为结构化对象(``CameraSpec``),字段对齐 Jellyfish ShotDetail 的\n camera_shot/angle/movement,便于后续导入器映射;取值由 CAS 本地枚举校验。\n- ``duration_seconds`` 允许小数,必须大于零。"},"ShotAssetOverviewItem":{"properties":{"key":{"type":"string","title":"Key","description":"合并键:type:name"},"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"实体类型:character/prop/scene/costume"},"name":{"type":"string","title":"Name","description":"资产名称"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"候选描述(来自 extraction payload)"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"缩略图或参考图文件 ID"},"source":{"type":"string","enum":["linked","candidate","both"],"title":"Source","description":"来源:linked/candidate/both"},"candidate_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Candidate Id","description":"候选项 ID"},"candidate_status":{"anyOf":[{"$ref":"#/components/schemas/ShotCandidateStatus"},{"type":"null"}],"description":"候选确认状态"},"linked_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Linked Entity Id","description":"当前已关联实体 ID"},"linked_image_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Linked Image Id","description":"当前已关联实体的 image 行 ID"},"is_linked":{"type":"boolean","title":"Is Linked","description":"当前是否已关联到镜头"}},"type":"object","required":["key","type","name","source","is_linked"],"title":"ShotAssetOverviewItem","description":"分镜资产总览项:统一返回已关联资产与提取候选的合并视图。"},"ShotAssetsOverviewRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"skip_extraction":{"type":"boolean","title":"Skip Extraction","description":"是否明确跳过提取"},"status":{"$ref":"#/components/schemas/ShotStatus","description":"镜头流程状态"},"summary":{"$ref":"#/components/schemas/ShotAssetsOverviewSummary","description":"总览统计"},"items":{"items":{"$ref":"#/components/schemas/ShotAssetOverviewItem"},"type":"array","title":"Items","description":"资产总览项"}},"type":"object","required":["shot_id","skip_extraction","status","summary"],"title":"ShotAssetsOverviewRead"},"ShotAssetsOverviewSummary":{"properties":{"linked_count":{"type":"integer","title":"Linked Count","description":"已关联项数量"},"pending_count":{"type":"integer","title":"Pending Count","description":"待确认候选数量"},"ignored_count":{"type":"integer","title":"Ignored Count","description":"已忽略候选数量"},"total_count":{"type":"integer","title":"Total Count","description":"总项数(含 ignored)"}},"type":"object","required":["linked_count","pending_count","ignored_count","total_count"],"title":"ShotAssetsOverviewSummary"},"ShotCandidateStatus":{"type":"string","enum":["pending","linked","ignored"],"title":"ShotCandidateStatus","description":"镜头提取候选确认状态。"},"ShotCandidateType":{"type":"string","enum":["character","scene","prop","costume"],"title":"ShotCandidateType","description":"镜头提取候选类型。"},"ShotCharacterLinkCreate":{"properties":{"shot_id":{"type":"string","title":"Shot Id"},"character_id":{"type":"string","title":"Character Id"},"index":{"type":"integer","title":"Index","default":0},"note":{"type":"string","title":"Note","default":""}},"type":"object","required":["shot_id","character_id"],"title":"ShotCharacterLinkCreate"},"ShotCharacterLinkRead":{"properties":{"id":{"type":"integer","title":"Id","description":"关联行 ID"},"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"character_id":{"type":"string","title":"Character Id","description":"角色 ID"},"index":{"type":"integer","title":"Index","description":"镜头内角色排序","default":0},"note":{"type":"string","title":"Note","description":"备注","default":""}},"type":"object","required":["id","shot_id","character_id"],"title":"ShotCharacterLinkRead"},"ShotCreate":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID"},"chapter_id":{"type":"string","title":"Chapter Id","description":"所属章节 ID"},"index":{"type":"integer","title":"Index","description":"镜头序号(章节内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"status":{"$ref":"#/components/schemas/ShotStatus","description":"镜头状态","default":"pending"},"skip_extraction":{"type":"boolean","title":"Skip Extraction","description":"是否明确跳过信息提取","default":false},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"generated_video_file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generated Video File Id","description":"已生成视频关联的文件 ID(files.id,type=video)"}},"type":"object","required":["id","chapter_id","index","title"],"title":"ShotCreate"},"ShotDetailCreate":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID(与 shots.id 共享主键)"},"camera_shot":{"$ref":"#/components/schemas/CameraShotType","description":"景别"},"angle":{"$ref":"#/components/schemas/CameraAngle","description":"机位角度"},"movement":{"$ref":"#/components/schemas/CameraMovement","description":"运镜方式"},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id","description":"关联场景 ID(可空)"},"duration":{"type":"integer","title":"Duration","description":"时长(秒)","default":0},"override_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Override Video Ratio","description":"分镜级视频比例覆盖;为空表示继承项目默认"},"mood_tags":{"items":{"type":"string"},"type":"array","title":"Mood Tags","description":"情绪标签"},"atmosphere":{"type":"string","title":"Atmosphere","description":"氛围描述","default":""},"follow_atmosphere":{"type":"boolean","title":"Follow Atmosphere","description":"是否沿用氛围","default":true},"has_bgm":{"type":"boolean","title":"Has Bgm","description":"是否包含 BGM","default":false},"vfx_type":{"$ref":"#/components/schemas/VFXType","description":"视效类型","default":"NONE"},"vfx_note":{"type":"string","title":"Vfx Note","description":"视效说明","default":""},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"动作拍点(按时间顺序排列)"},"first_frame_prompt":{"type":"string","title":"First Frame Prompt","description":"镜头分镜首帧提示词","default":""},"last_frame_prompt":{"type":"string","title":"Last Frame Prompt","description":"镜头分镜尾帧提示词","default":""},"key_frame_prompt":{"type":"string","title":"Key Frame Prompt","description":"镜头分镜关键帧提示词","default":""}},"type":"object","required":["id","camera_shot","angle","movement"],"title":"ShotDetailCreate"},"ShotDetailRead":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID(与 shots.id 共享主键)"},"camera_shot":{"$ref":"#/components/schemas/CameraShotType","description":"景别"},"angle":{"$ref":"#/components/schemas/CameraAngle","description":"机位角度"},"movement":{"$ref":"#/components/schemas/CameraMovement","description":"运镜方式"},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id","description":"关联场景 ID(可空)"},"duration":{"type":"integer","title":"Duration","description":"时长(秒)","default":0},"override_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Override Video Ratio","description":"分镜级视频比例覆盖;为空表示继承项目默认"},"mood_tags":{"items":{"type":"string"},"type":"array","title":"Mood Tags","description":"情绪标签"},"atmosphere":{"type":"string","title":"Atmosphere","description":"氛围描述","default":""},"follow_atmosphere":{"type":"boolean","title":"Follow Atmosphere","description":"是否沿用氛围","default":true},"has_bgm":{"type":"boolean","title":"Has Bgm","description":"是否包含 BGM","default":false},"vfx_type":{"$ref":"#/components/schemas/VFXType","description":"视效类型","default":"NONE"},"vfx_note":{"type":"string","title":"Vfx Note","description":"视效说明","default":""},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"动作拍点(按时间顺序排列)"},"first_frame_prompt":{"type":"string","title":"First Frame Prompt","description":"镜头分镜首帧提示词","default":""},"last_frame_prompt":{"type":"string","title":"Last Frame Prompt","description":"镜头分镜尾帧提示词","default":""},"key_frame_prompt":{"type":"string","title":"Key Frame Prompt","description":"镜头分镜关键帧提示词","default":""}},"type":"object","required":["id","camera_shot","angle","movement"],"title":"ShotDetailRead"},"ShotDetailUpdate":{"properties":{"camera_shot":{"anyOf":[{"$ref":"#/components/schemas/CameraShotType"},{"type":"null"}]},"angle":{"anyOf":[{"$ref":"#/components/schemas/CameraAngle"},{"type":"null"}]},"movement":{"anyOf":[{"$ref":"#/components/schemas/CameraMovement"},{"type":"null"}]},"scene_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Id"},"duration":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration"},"override_video_ratio":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Override Video Ratio"},"mood_tags":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Mood Tags"},"atmosphere":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Atmosphere"},"follow_atmosphere":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Follow Atmosphere"},"has_bgm":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Has Bgm"},"vfx_type":{"anyOf":[{"$ref":"#/components/schemas/VFXType"},{"type":"null"}]},"vfx_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Vfx Note"},"action_beats":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Action Beats"},"first_frame_prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"First Frame Prompt"},"last_frame_prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Frame Prompt"},"key_frame_prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key Frame Prompt"}},"type":"object","title":"ShotDetailUpdate"},"ShotDialogLineCreate":{"properties":{"shot_detail_id":{"type":"string","title":"Shot Detail Id"},"index":{"type":"integer","title":"Index","default":0},"text":{"type":"string","title":"Text"},"line_mode":{"$ref":"#/components/schemas/DialogueLineMode","default":"DIALOGUE"},"speaker_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Id"},"target_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Character Id"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name"}},"type":"object","required":["shot_detail_id","text"],"title":"ShotDialogLineCreate"},"ShotDialogLineRead":{"properties":{"id":{"type":"integer","title":"Id","description":"对话行 ID"},"shot_detail_id":{"type":"string","title":"Shot Detail Id","description":"所属镜头细节 ID"},"index":{"type":"integer","title":"Index","description":"行号(镜头内排序)","default":0},"text":{"type":"string","title":"Text","description":"台词内容"},"line_mode":{"$ref":"#/components/schemas/DialogueLineMode","description":"对白模式","default":"DIALOGUE"},"speaker_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Id","description":"说话角色 ID"},"target_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Character Id","description":"听者角色 ID"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"说话角色名称(用于回填关联;可空)"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"听者角色名称(用于回填关联;可空)"}},"type":"object","required":["id","shot_detail_id","text"],"title":"ShotDialogLineRead"},"ShotDialogLineUpdate":{"properties":{"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Text"},"line_mode":{"anyOf":[{"$ref":"#/components/schemas/DialogueLineMode"},{"type":"null"}]},"speaker_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Id"},"target_character_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Character Id"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name"}},"type":"object","title":"ShotDialogLineUpdate"},"ShotDialogueCandidateStatus":{"type":"string","enum":["pending","accepted","ignored"],"title":"ShotDialogueCandidateStatus","description":"镜头对白提取候选确认状态。"},"ShotDivision":{"properties":{"index":{"type":"integer","minimum":1.0,"title":"Index","description":"镜头序号(章节内唯一)"},"start_line":{"type":"integer","minimum":1.0,"title":"Start Line","description":"起始行号(1-based)"},"end_line":{"type":"integer","minimum":1.0,"title":"End Line","description":"结束行号(1-based)"},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"镜头对应的剧本摘录/文本"},"shot_name":{"type":"string","title":"Shot Name","description":"镜头名称(分镜名/镜头标题)","default":""},"time_of_day":{"anyOf":[{"type":"string","enum":["DAY","NIGHT","DAWN","DUSK","UNKNOWN","日","夜","黎明","黄昏","不明","未知"]},{"type":"null"}],"title":"Time Of Day","description":"时间(日/夜/未知等,可选)"}},"additionalProperties":false,"type":"object","required":["index","start_line","end_line","script_excerpt"],"title":"ShotDivision","description":"剧本分镜中的单镜信息:行号 + 预览文本(可选弱语义)。"},"ShotExtractedCandidateLinkRequest":{"properties":{"linked_entity_id":{"type":"string","title":"Linked Entity Id","description":"确认关联到的实体 ID"}},"type":"object","required":["linked_entity_id"],"title":"ShotExtractedCandidateLinkRequest"},"ShotExtractedCandidateRead":{"properties":{"id":{"type":"integer","title":"Id","description":"候选项 ID"},"shot_id":{"type":"string","title":"Shot Id","description":"所属镜头 ID"},"candidate_type":{"$ref":"#/components/schemas/ShotCandidateType","description":"候选类型"},"candidate_name":{"type":"string","title":"Candidate Name","description":"提取出的候选名称"},"candidate_status":{"$ref":"#/components/schemas/ShotCandidateStatus","description":"候选确认状态"},"linked_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Linked Entity Id","description":"已关联实体 ID"},"source":{"type":"string","title":"Source","description":"候选来源"},"payload":{"additionalProperties":true,"type":"object","title":"Payload","description":"候选附加信息"},"confirmed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Confirmed At","description":"确认时间"},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"创建时间"},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"更新时间"}},"type":"object","required":["id","shot_id","candidate_type","candidate_name","candidate_status","source","created_at","updated_at"],"title":"ShotExtractedCandidateRead"},"ShotExtractedDialogueCandidateAcceptRequest":{"properties":{"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index","description":"写入对白行时使用的排序;为空则使用候选排序"},"text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Text","description":"接受时可覆盖对白文本"},"line_mode":{"anyOf":[{"$ref":"#/components/schemas/DialogueLineMode"},{"type":"null"}],"description":"接受时可覆盖对白模式"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"接受时可覆盖说话角色名称"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"接受时可覆盖听者角色名称"}},"type":"object","title":"ShotExtractedDialogueCandidateAcceptRequest"},"ShotExtractedDialogueCandidateRead":{"properties":{"id":{"type":"integer","title":"Id","description":"对白候选项 ID"},"shot_id":{"type":"string","title":"Shot Id","description":"所属镜头 ID"},"index":{"type":"integer","title":"Index","description":"镜头内对白候选排序"},"text":{"type":"string","title":"Text","description":"提取出的对白文本"},"line_mode":{"$ref":"#/components/schemas/DialogueLineMode","description":"对白模式"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"说话角色名称"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"听者角色名称"},"candidate_status":{"$ref":"#/components/schemas/ShotDialogueCandidateStatus","description":"对白候选确认状态"},"linked_dialog_line_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Linked Dialog Line Id","description":"已接受后关联的对白行 ID"},"source":{"type":"string","title":"Source","description":"候选来源"},"payload":{"additionalProperties":true,"type":"object","title":"Payload","description":"候选附加信息"},"confirmed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Confirmed At","description":"确认时间"},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"创建时间"},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"更新时间"}},"type":"object","required":["id","shot_id","index","text","line_mode","candidate_status","source","created_at","updated_at"],"title":"ShotExtractedDialogueCandidateRead"},"ShotExtractionSummaryRead":{"properties":{"state":{"type":"string","enum":["not_extracted","extracted_empty","extracted_pending","extracted_resolved","skipped"],"title":"State","description":"镜头提取确认状态摘要"},"has_extracted":{"type":"boolean","title":"Has Extracted","description":"是否已执行过提取"},"last_extracted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Extracted At","description":"最近一次提取完成时间"},"asset_candidate_total":{"type":"integer","title":"Asset Candidate Total","description":"资产候选总数","default":0},"dialogue_candidate_total":{"type":"integer","title":"Dialogue Candidate Total","description":"对白候选总数","default":0},"pending_asset_count":{"type":"integer","title":"Pending Asset Count","description":"待确认资产候选数","default":0},"pending_dialogue_count":{"type":"integer","title":"Pending Dialogue Count","description":"待确认对白候选数","default":0}},"type":"object","required":["state","has_extracted"],"title":"ShotExtractionSummaryRead"},"ShotFrameImageCreate":{"properties":{"shot_detail_id":{"type":"string","title":"Shot Detail Id"},"frame_type":{"$ref":"#/components/schemas/ShotFrameType"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id"},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width"},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height"},"format":{"type":"string","title":"Format","default":"png"}},"type":"object","required":["shot_detail_id","frame_type"],"title":"ShotFrameImageCreate"},"ShotFrameImageRead":{"properties":{"id":{"type":"integer","title":"Id","description":"图片行 ID"},"shot_detail_id":{"type":"string","title":"Shot Detail Id","description":"所属镜头细节 ID"},"frame_type":{"$ref":"#/components/schemas/ShotFrameType","description":"帧类型:first/last/key"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联的 FileItem ID(可为空,允许先创建占位)"},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width","description":"宽(px)"},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height","description":"高(px)"},"format":{"type":"string","title":"Format","description":"格式","default":"png"}},"type":"object","required":["id","shot_detail_id","frame_type"],"title":"ShotFrameImageRead"},"ShotFrameImageTaskRequest":{"properties":{"model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Id","description":"可选模型 ID(models.id);不传则使用 ModelSettings.default_image_model_id;Provider 由模型关联反查"},"frame_type":{"$ref":"#/components/schemas/ShotFrameType","description":"first | last | key"},"prompt":{"type":"string","minLength":1,"title":"Prompt","description":"提示词(由前端传入,创建任务接口必填)。"},"images":{"items":{"$ref":"#/components/schemas/ShotLinkedAssetItem"},"type":"array","title":"Images","description":"参考资产条目列表(可多张,顺序有效)。后端会使用 item.file_id 作为参考图;无效条目会被跳过。"},"target_ratio":{"type":"string","enum":["16:9","4:3","1:1","3:4","9:16","21:9","3:2","2:3"],"title":"Target Ratio","description":"目标视频画幅比例;关键帧将按该画幅生成,以提升后续视频参考稳定性"},"resolution_profile":{"anyOf":[{"type":"string","enum":["standard","high"]},{"type":"null"}],"title":"Resolution Profile","description":"关键帧输出分辨率档位,默认 standard","default":"standard"}},"type":"object","required":["frame_type","prompt","target_ratio"],"title":"ShotFrameImageTaskRequest","description":"镜头分镜帧图片生成请求体:只根据 `shot_id + frame_type` 定位 ShotFrameImage。\n\n用于替代旧接口中通过 `image_id` 直接传入 ShotFrameImage.id 的方式。"},"ShotFrameImageUpdate":{"properties":{"frame_type":{"anyOf":[{"$ref":"#/components/schemas/ShotFrameType"},{"type":"null"}]},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id"},"width":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Width"},"height":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Height"},"format":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Format"}},"type":"object","title":"ShotFrameImageUpdate"},"ShotFramePromptMappingRead":{"properties":{"token":{"type":"string","title":"Token","description":"提示词中的图片占位 token,如 图1 / 图2"},"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"实体类型:character/prop/scene/costume"},"id":{"type":"string","title":"Id","description":"实体 ID(如 character_id/prop_id/scene_id/costume_id)"},"name":{"type":"string","title":"Name","description":"实体名称"},"file_id":{"type":"string","title":"File Id","description":"本次渲染与生成使用的文件 ID"}},"type":"object","required":["token","type","id","name","file_id"],"title":"ShotFramePromptMappingRead","description":"关键帧提示词渲染后的图片映射关系。"},"ShotFramePromptRenderRequest":{"properties":{"frame_type":{"$ref":"#/components/schemas/ShotFrameType","description":"first | last | key"},"prompt":{"type":"string","minLength":1,"title":"Prompt","description":"原始基础提示词。渲染接口要求显式传入,用于生成最终提示词。"},"images":{"items":{"$ref":"#/components/schemas/ShotLinkedAssetItem"},"type":"array","title":"Images","description":"参考资产条目列表(可多张,顺序有效)。后端会使用 item.file_id 作为参考图;无效条目会被跳过。"}},"type":"object","required":["frame_type","prompt"],"title":"ShotFramePromptRenderRequest","description":"镜头分镜帧提示词渲染请求体。"},"ShotFramePromptRequest":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"frame_type":{"type":"string","title":"Frame Type","description":"first | last | key"}},"type":"object","required":["shot_id","frame_type"],"title":"ShotFramePromptRequest","description":"镜头分镜帧提示词生成任务请求。"},"ShotFrameType":{"type":"string","enum":["first","last","key"],"title":"ShotFrameType","description":"镜头分镜帧类型:首帧/尾帧/关键帧。"},"ShotLinkedAssetItem":{"properties":{"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"实体类型:character/prop/scene/costume"},"id":{"type":"string","title":"Id","description":"实体 ID(如 character_id/prop_id/scene_id/costume_id)"},"image_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Image Id","description":"最佳缩略图对应的 image 行 ID(如 PropImage.id);无图则为 null"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"最佳缩略图对应的文件 ID(files.id);用于参考图输入;无图则为 null"},"name":{"type":"string","title":"Name","description":"实体名称"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图下载地址(/api/v1/studio/files/{file_id}/download)","default":""}},"type":"object","required":["type","id","name"],"title":"ShotLinkedAssetItem","description":"按分镜聚合返回的关联资产条目(角色/道具/场景/服装)。"},"ShotPreparationLinkEntityType":{"type":"string","enum":["character","scene","prop","costume"],"title":"ShotPreparationLinkEntityType"},"ShotPreparationLinkRequest":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"项目 ID"},"chapter_id":{"type":"string","title":"Chapter Id","description":"章节 ID"},"entity_type":{"$ref":"#/components/schemas/ShotPreparationLinkEntityType","description":"准备页关联的实体类型"},"linked_entity_id":{"type":"string","title":"Linked Entity Id","description":"要关联的实体 ID"}},"type":"object","required":["project_id","chapter_id","entity_type","linked_entity_id"],"title":"ShotPreparationLinkRequest"},"ShotPreparationMutationAction":{"type":"string","enum":["link_asset_candidate","ignore_asset_candidate","accept_dialogue_candidate","ignore_dialogue_candidate","skip_extraction","resume_extraction"],"title":"ShotPreparationMutationAction"},"ShotPreparationMutationResultRead":{"properties":{"action":{"$ref":"#/components/schemas/ShotPreparationMutationAction","description":"本次执行的准备页动作"},"state":{"$ref":"#/components/schemas/ShotPreparationStateRead","description":"动作完成后的最新准备页聚合状态"}},"type":"object","required":["action","state"],"title":"ShotPreparationMutationResultRead","description":"准备页命令执行后的统一响应。"},"ShotPreparationStateRead":{"properties":{"shot":{"$ref":"#/components/schemas/ShotRead","description":"当前镜头最新状态"},"assets_overview":{"$ref":"#/components/schemas/ShotAssetsOverviewRead","description":"资产确认区聚合状态"},"dialogue_candidates":{"items":{"$ref":"#/components/schemas/ShotExtractedDialogueCandidateRead"},"type":"array","title":"Dialogue Candidates","description":"当前待处理/已存在的对白候选"},"saved_dialogue_lines":{"items":{"$ref":"#/components/schemas/ShotDialogLineRead"},"type":"array","title":"Saved Dialogue Lines","description":"当前已保存的对白行"},"pending_confirm_count":{"type":"integer","title":"Pending Confirm Count","description":"当前仍待确认的总数量(资产 + 对白)"},"basic_info_ready":{"type":"boolean","title":"Basic Info Ready","description":"标题与剧本摘录是否已补齐"},"semantic_defaults_ready":{"type":"boolean","title":"Semantic Defaults Ready","description":"镜头语言默认值是否已确认"},"action_beats_ready":{"type":"boolean","title":"Action Beats Ready","description":"动作拍点是否已确认"},"action_beats_count":{"type":"integer","title":"Action Beats Count","description":"当前已确认动作拍点数量","default":0},"action_beat_phases":{"items":{"$ref":"#/components/schemas/ActionBeatPhaseRead"},"type":"array","title":"Action Beat Phases","description":"当前动作拍点的阶段推断结果"},"ready_for_generation":{"type":"boolean","title":"Ready For Generation","description":"当前镜头是否已完成准备,可进入后续生成"}},"type":"object","required":["shot","assets_overview","pending_confirm_count","basic_info_ready","semantic_defaults_ready","action_beats_ready","ready_for_generation"],"title":"ShotPreparationStateRead","description":"分镜准备页聚合状态。"},"ShotPromptAssetRef":{"properties":{"type":{"type":"string","enum":["character","prop","scene","costume"],"title":"Type","description":"资产类型"},"name":{"type":"string","title":"Name","description":"资产名称"},"description":{"type":"string","title":"Description","description":"资产描述或提取候选描述","default":""},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"可作为参考图的文件 ID"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图"}},"type":"object","required":["type","name"],"title":"ShotPromptAssetRef","description":"用于提示词渲染的镜头资产引用。"},"ShotPromptCameraInfo":{"properties":{"camera_shot":{"type":"string","title":"Camera Shot","description":"景别","default":""},"angle":{"type":"string","title":"Angle","description":"机位角度","default":""},"movement":{"type":"string","title":"Movement","description":"运镜方式","default":""},"duration":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration","description":"镜头时长(秒)"}},"type":"object","title":"ShotPromptCameraInfo","description":"用于提示词渲染的镜头语言信息。"},"ShotRead":{"properties":{"id":{"type":"string","title":"Id","description":"镜头 ID"},"chapter_id":{"type":"string","title":"Chapter Id","description":"所属章节 ID"},"index":{"type":"integer","title":"Index","description":"镜头序号(章节内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题"},"thumbnail":{"type":"string","title":"Thumbnail","description":"缩略图 URL/路径","default":""},"status":{"$ref":"#/components/schemas/ShotStatus","description":"镜头状态","default":"pending"},"skip_extraction":{"type":"boolean","title":"Skip Extraction","description":"是否明确跳过信息提取","default":false},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"generated_video_file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generated Video File Id","description":"已生成视频关联的文件 ID(files.id,type=video)"},"last_extracted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Extracted At","description":"最近一次完成信息提取的时间"},"extraction":{"$ref":"#/components/schemas/ShotExtractionSummaryRead","description":"镜头提取状态摘要"}},"type":"object","required":["id","chapter_id","index","title","extraction"],"title":"ShotRead"},"ShotRuntimeSummaryRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"has_active_tasks":{"type":"boolean","title":"Has Active Tasks","description":"是否存在进行中的关联任务"},"has_active_video_tasks":{"type":"boolean","title":"Has Active Video Tasks","description":"是否存在进行中的视频任务"},"has_active_prompt_tasks":{"type":"boolean","title":"Has Active Prompt Tasks","description":"是否存在进行中的提示词任务"},"has_active_frame_tasks":{"type":"boolean","title":"Has Active Frame Tasks","description":"是否存在进行中的分镜帧图片任务"},"active_task_count":{"type":"integer","title":"Active Task Count","description":"进行中的唯一任务数"}},"type":"object","required":["shot_id","has_active_tasks","has_active_video_tasks","has_active_prompt_tasks","has_active_frame_tasks","active_task_count"],"title":"ShotRuntimeSummaryRead"},"ShotSemanticSuggestion":{"properties":{"camera_shot":{"anyOf":[{"$ref":"#/components/schemas/CameraShotType"},{"type":"null"}],"description":"建议景别"},"angle":{"anyOf":[{"$ref":"#/components/schemas/CameraAngle"},{"type":"null"}],"description":"建议机位"},"movement":{"anyOf":[{"$ref":"#/components/schemas/CameraMovement"},{"type":"null"}],"description":"建议运镜"},"duration":{"anyOf":[{"type":"integer","minimum":1.0},{"type":"null"}],"title":"Duration","description":"建议时长(秒)"},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"按时间顺序排列的动作拍点"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"不确定项说明"}},"additionalProperties":false,"type":"object","title":"ShotSemanticSuggestion","description":"镜头语义默认建议:用于准备阶段初始化镜头语言与动作拍点。"},"ShotSkipExtractionUpdate":{"properties":{"skip":{"type":"boolean","title":"Skip","description":"是否明确跳过信息提取"}},"type":"object","required":["skip"],"title":"ShotSkipExtractionUpdate"},"ShotStatus":{"type":"string","enum":["pending","generating","ready"],"title":"ShotStatus","description":"镜头生成状态(更多是“生产流程”而非剧情状态)。"},"ShotUpdate":{"properties":{"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id"},"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail"},"status":{"anyOf":[{"$ref":"#/components/schemas/ShotStatus"},{"type":"null"}]},"skip_extraction":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Skip Extraction"},"script_excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Script Excerpt"},"generated_video_file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Generated Video File Id"}},"type":"object","title":"ShotUpdate"},"ShotV11":{"properties":{"shot_id":{"type":"string","minLength":1,"title":"Shot Id","description":"镜头 ID(本集内唯一,非空)"},"sequence":{"type":"integer","exclusiveMinimum":0.0,"title":"Sequence","description":"镜头顺序(正整数,本集内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题/分镜名","default":""},"duration_seconds":{"type":"number","exclusiveMinimum":0.0,"title":"Duration Seconds","description":"镜头时长(秒),必须大于零"},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"镜头对应的剧本摘录","default":""},"camera":{"anyOf":[{"$ref":"#/components/schemas/CameraSpec"},{"type":"null"}],"description":"结构化相机描述(景别/角度/运镜,可选)"},"action":{"type":"string","title":"Action","description":"镜头内动作/视觉描述","default":""},"dialogue":{"items":{"$ref":"#/components/schemas/DialogueLine"},"type":"array","title":"Dialogue","description":"镜头内对白列表"},"character_keys":{"items":{"type":"string"},"type":"array","title":"Character Keys","description":"出场角色键(须存在于 characters)"},"scene_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Key","description":"场景键(可选;提供则须存在于 assets.scenes)"},"prop_keys":{"items":{"type":"string"},"type":"array","title":"Prop Keys","description":"道具键(须存在于 assets.props)"},"costume_keys":{"items":{"type":"string"},"type":"array","title":"Costume Keys","description":"服装键(须存在于 assets.costumes)"},"image_prompt":{"type":"string","title":"Image Prompt","description":"图像生成提示词","default":""},"video_prompt":{"type":"string","title":"Video Prompt","description":"视频生成提示词","default":""},"negative_prompt":{"type":"string","title":"Negative Prompt","description":"反向提示词","default":""},"continuity_notes":{"type":"string","title":"Continuity Notes","description":"镜头连续性备注","default":""},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","description":"镜头级附加元信息"},"beginning_state":{"type":"string","title":"Beginning State","description":"起始状态(生成用)","default":""},"ending_state":{"type":"string","title":"Ending State","description":"结束状态(生成用)","default":""},"generation_risks":{"items":{"type":"string"},"type":"array","title":"Generation Risks","description":"已知生成风险"},"regeneration_fallback":{"anyOf":[{"$ref":"#/components/schemas/RegenerationFallback"},{"type":"null"}],"description":"仅恢复用兜底方案"},"overlay_ids":{"items":{"type":"string"},"type":"array","title":"Overlay Ids","description":"关联的后期叠加 ID"}},"additionalProperties":false,"type":"object","required":["shot_id","sequence","duration_seconds"],"title":"ShotV11","description":"v1.1 镜头:在 v1 ``Shot`` 之上仅新增五个可选字段。\n\n刻意不新增:连续性字段(用 ``continuity_notes``)、运镜字段(用 ``camera.movement``)、\n任何镜头相对时间字段。"},"ShotVideoPromptPackRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"title":{"type":"string","title":"Title","description":"镜头标题","default":""},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"action_beats":{"items":{"type":"string"},"type":"array","title":"Action Beats","description":"动作/场景要点"},"action_beat_phases":{"items":{"$ref":"#/components/schemas/ActionBeatPhaseRead"},"type":"array","title":"Action Beat Phases","description":"动作拍点的阶段推断结果"},"previous_shot_summary":{"type":"string","title":"Previous Shot Summary","description":"上一镜头摘要,用于提示词连续性约束","default":""},"next_shot_goal":{"type":"string","title":"Next Shot Goal","description":"下一镜头目标,用于提示词连续性约束","default":""},"continuity_guidance":{"type":"string","title":"Continuity Guidance","description":"当前镜头与相邻镜头的承接建议","default":""},"composition_anchor":{"type":"string","title":"Composition Anchor","description":"当前镜头的构图与空间锚点建议","default":""},"screen_direction_guidance":{"type":"string","title":"Screen Direction Guidance","description":"当前镜头的人物朝向、视线与左右轴线建议","default":""},"dialogue_summary":{"type":"string","title":"Dialogue Summary","description":"对白摘要","default":""},"characters":{"items":{"$ref":"#/components/schemas/ShotPromptAssetRef"},"type":"array","title":"Characters","description":"角色引用"},"scene":{"anyOf":[{"$ref":"#/components/schemas/ShotPromptAssetRef"},{"type":"null"}],"description":"场景引用"},"props":{"items":{"$ref":"#/components/schemas/ShotPromptAssetRef"},"type":"array","title":"Props","description":"道具引用"},"costumes":{"items":{"$ref":"#/components/schemas/ShotPromptAssetRef"},"type":"array","title":"Costumes","description":"服装引用"},"camera":{"$ref":"#/components/schemas/ShotPromptCameraInfo","description":"镜头语言"},"atmosphere":{"type":"string","title":"Atmosphere","description":"氛围描述","default":""},"visual_style":{"type":"string","title":"Visual Style","description":"项目视觉风格","default":""},"style":{"type":"string","title":"Style","description":"项目题材/风格","default":""},"negative_prompt":{"type":"string","title":"Negative Prompt","description":"默认负面提示词","default":""}},"type":"object","required":["shot_id"],"title":"ShotVideoPromptPackRead","description":"视频提示词渲染前的标准上下文包。"},"ShotVideoPromptPreviewRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"template_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Template Id","description":"使用的提示词模板 ID"},"template_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Template Name","description":"使用的提示词模板名称"},"rendered_prompt":{"type":"string","title":"Rendered Prompt","description":"渲染后的提示词"},"pack":{"$ref":"#/components/schemas/ShotVideoPromptPackRead","description":"渲染上下文包"},"warnings":{"items":{"type":"string"},"type":"array","title":"Warnings","description":"渲染时发现的非阻塞提示"}},"type":"object","required":["shot_id","rendered_prompt","pack"],"title":"ShotVideoPromptPreviewRead","description":"视频提示词预览结果。"},"ShotVideoReadinessCheck":{"properties":{"key":{"type":"string","title":"Key","description":"检查项 key"},"ok":{"type":"boolean","title":"Ok","description":"是否通过"},"message":{"type":"string","title":"Message","description":"面向前端展示的说明"}},"type":"object","required":["key","ok","message"],"title":"ShotVideoReadinessCheck","description":"单项视频生成准备度检查结果。"},"ShotVideoReadinessRead":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"reference_mode":{"type":"string","title":"Reference Mode","description":"参考模式"},"ready":{"type":"boolean","title":"Ready","description":"是否满足当前 reference_mode 下的视频生成条件"},"checks":{"items":{"$ref":"#/components/schemas/ShotVideoReadinessCheck"},"type":"array","title":"Checks","description":"准备度检查项"}},"type":"object","required":["shot_id","reference_mode","ready"],"title":"ShotVideoReadinessRead","description":"镜头视频生成准备度。"},"StudioAssetDraft":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"资产 ID(已落库时回填,如 scene_id / prop_id / costume_id)"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联的文件 ID(可空)"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图下载地址(可空)"},"name":{"type":"string","title":"Name","description":"名称(同项目内建议唯一)"},"description":{"type":"string","title":"Description","description":"描述","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签"},"prompt_template_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prompt Template Id","description":"提示词模板 ID(可空)"},"view_count":{"type":"integer","minimum":1.0,"title":"View Count","description":"计划生成视角图数量","default":1}},"additionalProperties":false,"type":"object","required":["name"],"title":"StudioAssetDraft","description":"Studio 资产草稿(Scene/Prop/Costume)。\n\n导入 API 未传 id 时由服务端生成;分镜详情回填时可带 scene_id/prop_id/costume_id。"},"StudioCharacterDraft":{"properties":{"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id","description":"角色 ID(已落库时回填 character_id)"},"file_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Id","description":"关联的文件 ID(可空)"},"thumbnail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Thumbnail","description":"缩略图下载地址(可空)"},"index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Index","description":"镜头内角色排序(shot_character_links.index)"},"name":{"type":"string","title":"Name","description":"角色名称(同项目内建议唯一)"},"description":{"type":"string","title":"Description","description":"角色描述","default":""},"tags":{"items":{"type":"string"},"type":"array","title":"Tags","description":"标签(可选)"},"costume_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Costume Name","description":"服装名称(可选,导入时映射到 costume_id)"},"prop_names":{"items":{"type":"string"},"type":"array","title":"Prop Names","description":"角色常用道具名称列表(可选)"}},"additionalProperties":false,"type":"object","required":["name"],"title":"StudioCharacterDraft","description":"Studio 角色草稿。\n\n导入 API 未传 id 时由服务端生成;分镜详情回填时可带 character_id。"},"StudioImageTaskRequest":{"properties":{"model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Id","description":"可选模型 ID(models.id);不传则使用 ModelSettings.default_image_model_id;Provider 由模型关联反查"},"image_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Image Id","description":"图片模型 ID,如 ActorImage.id / SceneImage.id / PropImage.id 等;必须与路径主体 ID 匹配"},"prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prompt","description":"提示词(由前端传入)。创建任务接口必填;render-prompt 接口可不传"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"参考图 file_id 列表(可多张,顺序有效)。创建任务接口会基于 file_id 从数据中解析为参考图"}},"type":"object","title":"StudioImageTaskRequest","description":"Studio 专用图片任务请求体:可选模型 ID,不传则用默认图片模型;供应商由模型反查。\n\nimage_id 表示具体的图片模型 ID,例如:\n- 演员图片:ActorImage.id\n- 场景图片:SceneImage.id\n- 道具图片:PropImage.id\n- 服装图片:CostumeImage.id\n- 角色图片:CharacterImage.id\n- 分镜帧图片:ShotFrameImage.id"},"StudioScriptExtractionDraft":{"properties":{"project_id":{"type":"string","title":"Project Id","description":"项目 ID(必填)"},"chapter_id":{"type":"string","title":"Chapter Id","description":"章节 ID(必填,用于创建 shots/links)"},"script_text":{"type":"string","title":"Script Text","description":"剧本文本(可为优化后版本)"},"characters":{"items":{"$ref":"#/components/schemas/StudioCharacterDraft"},"type":"array","title":"Characters"},"scenes":{"items":{"$ref":"#/components/schemas/StudioAssetDraft"},"type":"array","title":"Scenes"},"props":{"items":{"$ref":"#/components/schemas/StudioAssetDraft"},"type":"array","title":"Props"},"costumes":{"items":{"$ref":"#/components/schemas/StudioAssetDraft"},"type":"array","title":"Costumes"},"shots":{"items":{"$ref":"#/components/schemas/StudioShotDraft"},"type":"array","title":"Shots","description":"镜头草稿列表"}},"additionalProperties":false,"type":"object","required":["project_id","chapter_id","script_text"],"title":"StudioScriptExtractionDraft","description":"用于导入 Studio 的提取结果草稿(name-based)。"},"StudioShotDraft":{"properties":{"index":{"type":"integer","minimum":1.0,"title":"Index","description":"镜头序号(章节内唯一)"},"title":{"type":"string","title":"Title","description":"镜头标题"},"script_excerpt":{"type":"string","title":"Script Excerpt","description":"剧本摘录","default":""},"scene_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scene Name","description":"场景名称(可选)"},"character_names":{"items":{"type":"string"},"type":"array","title":"Character Names","description":"本镜出现角色名称列表"},"prop_names":{"items":{"type":"string"},"type":"array","title":"Prop Names","description":"本镜关键道具名称列表"},"costume_names":{"items":{"type":"string"},"type":"array","title":"Costume Names","description":"本镜服装名称列表"},"dialogue_lines":{"items":{"$ref":"#/components/schemas/StudioShotDraftDialogueLine"},"type":"array","title":"Dialogue Lines","description":"对白列表"},"actions":{"items":{"type":"string"},"type":"array","title":"Actions","description":"动作/场景描述"},"semantic_suggestion":{"anyOf":[{"$ref":"#/components/schemas/ShotSemanticSuggestion"},{"type":"null"}],"description":"镜头语言默认建议与动作拍点候选"}},"additionalProperties":false,"type":"object","required":["index","title"],"title":"StudioShotDraft","description":"镜头草稿:不含 shot_id,由导入 API 生成;引用实体用 name。"},"StudioShotDraftDialogueLine":{"properties":{"index":{"type":"integer","minimum":0.0,"title":"Index","description":"镜头内排序","default":0},"text":{"type":"string","title":"Text","description":"台词内容"},"line_mode":{"type":"string","enum":["DIALOGUE","VOICE_OVER","OFF_SCREEN","PHONE"],"title":"Line Mode","description":"对白模式","default":"DIALOGUE"},"speaker_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Name","description":"说话角色名称(可空)"},"target_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Target Name","description":"听者角色名称(可空)"}},"additionalProperties":false,"type":"object","required":["text"],"title":"StudioShotDraftDialogueLine","description":"镜头对白草稿:speaker/target 使用角色 name,导入时映射为 character_id。"},"StyleOption":{"properties":{"value":{"type":"string","title":"Value","description":"选项值"},"label":{"type":"string","title":"Label","description":"选项展示文案"}},"type":"object","required":["value","label"],"title":"StyleOption","description":"通用下拉选项。"},"SubtitleArtifact":{"properties":{"file_id":{"type":"string","title":"File Id","description":"Jellyfish files.id"},"language_tag":{"type":"string","title":"Language Tag","description":"BCP 47 语言标签,如 zh-Hant"},"storage_key":{"type":"string","title":"Storage Key","description":"对象存储 key(确定性)"},"cue_count":{"type":"integer","title":"Cue Count","description":"cue 数量"},"byte_size":{"type":"integer","title":"Byte Size","description":"WebVTT 字节数"},"created":{"type":"boolean","title":"Created","description":"true=本次新建;false=复用既有产物并就地更新"}},"additionalProperties":false,"type":"object","required":["file_id","language_tag","storage_key","cue_count","byte_size","created"],"title":"SubtitleArtifact","description":"一条字幕产物(WebVTT)在导入结果中的表示。"},"SubtitleCue":{"properties":{"cue_id":{"type":"string","minLength":1,"title":"Cue Id","description":"cue 稳定 ID(轨内唯一)"},"start_ms":{"type":"integer","minimum":0.0,"title":"Start Ms","description":"入点(episode-absolute 毫秒)"},"end_ms":{"type":"integer","exclusiveMinimum":0.0,"title":"End Ms","description":"出点(必须大于 start_ms)"},"text":{"type":"string","minLength":1,"title":"Text","description":"译文(非空)"},"speaker_character_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker Character Key","description":"说话角色键(须存在于 characters)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"关联镜头(仅关联,不构成第二套时间真相)"}},"additionalProperties":false,"type":"object","required":["cue_id","start_ms","end_ms","text"],"title":"SubtitleCue","description":"字幕单条 cue;时间为 episode-absolute 整数毫秒。"},"SubtitleTrack":{"properties":{"language_tag":{"type":"string","minLength":1,"title":"Language Tag","description":"BCP 47 语言标签,如 zh-Hant"},"is_primary":{"type":"boolean","title":"Is Primary","description":"是否为主轨","default":false},"rendering":{"type":"string","enum":["post_production","burned_in","sidecar"],"title":"Rendering","description":"渲染方式(声明性;默认后期)","default":"post_production"},"cues":{"items":{"$ref":"#/components/schemas/SubtitleCue"},"type":"array","title":"Cues","description":"cue 列表(可为空,但后期阶段起视为无效)"}},"additionalProperties":false,"type":"object","required":["language_tag","cues"],"title":"SubtitleTrack","description":"一条字幕轨。渲染默认属于后期,不进入 AI 生成。"},"TaskCancelRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/TaskStatus"},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已登记取消请求"},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"effective_immediately":{"type":"boolean","title":"Effective Immediately","description":"是否已立即取消完成","default":false}},"type":"object","required":["task_id","status","cancel_requested"],"title":"TaskCancelRead"},"TaskCancelRequest":{"properties":{"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason","description":"取消原因(可选)"}},"type":"object","title":"TaskCancelRequest"},"TaskCreated":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"任务 ID"}},"type":"object","required":["task_id"],"title":"TaskCreated"},"TaskLinkAdoptRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"link_type":{"type":"string","title":"Link Type","description":"project | chapter | shot"},"entity_id":{"type":"string","title":"Entity Id","description":"项目/章节/镜头 ID"},"is_adopted":{"type":"boolean","title":"Is Adopted","description":"是否采用(仅可正向变更为 true)"}},"type":"object","required":["task_id","link_type","entity_id","is_adopted"],"title":"TaskLinkAdoptRead","description":"采用状态更新结果。"},"TaskLinkAdoptRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"绑定项目 ID(可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"绑定章节 ID(可选)"},"shot_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Shot Id","description":"绑定镜头 ID(可选)"},"task_id":{"type":"string","title":"Task Id","description":"任务 ID"}},"type":"object","required":["task_id"],"title":"TaskLinkAdoptRequest","description":"更新采用状态请求:task_id + 三选一绑定对象(project_id/chapter_id/shot_id)。"},"TaskListItemRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"task_kind":{"type":"string","title":"Task Kind","description":"业务任务类型"},"status":{"$ref":"#/components/schemas/TaskStatus"},"progress":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Progress"},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已请求取消","default":false},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"started_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Started At Ts","description":"任务开始执行时间戳"},"finished_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Finished At Ts","description":"任务结束时间戳"},"elapsed_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Elapsed Ms","description":"任务累计执行耗时(毫秒)"},"created_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Created At Ts","description":"任务创建时间戳"},"updated_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Updated At Ts","description":"任务更新时间戳"},"executor_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Executor Type","description":"执行器类型,如 celery"},"executor_task_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Executor Task Id","description":"执行器侧任务 ID"},"relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Type","description":"业务关联类型"},"relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Relation Entity Id","description":"业务关联实体 ID"},"resource_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resource Type","description":"资源类型"},"navigate_relation_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Navigate Relation Type","description":"前端默认跳转关联类型"},"navigate_relation_entity_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Navigate Relation Entity Id","description":"前端默认跳转关联实体 ID"}},"type":"object","required":["task_id","task_kind","status","progress"],"title":"TaskListItemRead"},"TaskResultRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/TaskStatus"},"progress":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Progress"},"result":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Result"},"error":{"type":"string","title":"Error","default":""},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已请求取消","default":false},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"started_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Started At Ts","description":"任务开始执行时间戳"},"finished_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Finished At Ts","description":"任务结束时间戳"},"elapsed_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Elapsed Ms","description":"任务累计执行耗时(毫秒)"}},"type":"object","required":["task_id","status","progress"],"title":"TaskResultRead"},"TaskStatus":{"type":"string","enum":["pending","running","streaming","succeeded","failed","cancelled"],"title":"TaskStatus","description":"任务状态枚举。"},"TaskStatusRead":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/TaskStatus"},"progress":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Progress"},"cancel_requested":{"type":"boolean","title":"Cancel Requested","description":"是否已请求取消","default":false},"cancel_requested_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cancel Requested At Ts","description":"请求取消时间戳"},"started_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Started At Ts","description":"任务开始执行时间戳"},"finished_at_ts":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Finished At Ts","description":"任务结束时间戳"},"elapsed_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Elapsed Ms","description":"任务累计执行耗时(毫秒)"}},"type":"object","required":["task_id","status","progress"],"title":"TaskStatusRead"},"VFXType":{"type":"string","enum":["NONE","PARTICLES","VOLUMETRIC_FOG","CG_DOUBLE","DIGITAL_ENVIRONMENT","MATTE_PAINTING","FIRE_SMOKE","WATER_SIM","DESTRUCTION","ENERGY_MAGIC","COMPOSITING_CLEANUP","SLOW_MOTION_TIME","OTHER"],"title":"VFXType","description":"视效类型(与 `app.schemas.skills.common.VFXType` 对齐,存英文 code)。"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VariantAnalysisRequest":{"properties":{"project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Id","description":"项目 ID(异步任务关联可选)"},"chapter_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chapter Id","description":"章节 ID(异步任务关联可选)"},"merged_library":{"additionalProperties":true,"type":"object","title":"Merged Library","description":"合并后的实体库(EntityLibrary 的序列化形式;来自 EntityMerger 输出的 merged_library)"},"all_shot_extractions":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"All Shot Extractions","description":"所有镜头提取结果"},"script_division":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Script Division","description":"脚本分镜结果(可选;ScriptDivisionResult 序列化),用于章节/段落分组"}},"type":"object","required":["merged_library","all_shot_extractions"],"title":"VariantAnalysisRequest","description":"变体分析请求。"},"VariantAnalysisResult":{"properties":{"costume_timelines":{"items":{"$ref":"#/components/schemas/CostumeTimeline"},"type":"array","title":"Costume Timelines","description":"各角色服装演变时间线"},"variant_suggestions":{"items":{"$ref":"#/components/schemas/VariantSuggestion"},"type":"array","title":"Variant Suggestions","description":"变体建议列表"},"chapter_variants":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Chapter Variants","description":"章节变体建议"},"notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Notes","description":"分析说明"}},"additionalProperties":false,"type":"object","title":"VariantAnalysisResult","description":"变体分析结果。"},"VariantSuggestion":{"properties":{"entity_id":{"type":"string","title":"Entity Id","description":"实体ID"},"entity_name":{"type":"string","title":"Entity Name","description":"实体名称"},"entity_type":{"type":"string","title":"Entity Type","description":"实体类型(character/scene/prop/location)"},"suggestion":{"type":"string","title":"Suggestion","description":"变体建议说明"},"affected_shots":{"items":{"type":"integer"},"type":"array","title":"Affected Shots","description":"涉及的镜头"},"evidence":{"items":{"$ref":"#/components/schemas/EvidenceSpan"},"type":"array","title":"Evidence","description":"原文依据(可选)"}},"additionalProperties":false,"type":"object","required":["entity_id","entity_name","entity_type","suggestion"],"title":"VariantSuggestion","description":"变体建议。"},"VideoGenerationOptionsRead":{"properties":{"provider":{"type":"string","title":"Provider","description":"供应商稳定键"},"model_id":{"type":"string","title":"Model Id","description":"默认视频模型 ID"},"model_name":{"type":"string","title":"Model Name","description":"默认视频模型名称"},"allowed_ratios":{"items":{"type":"string"},"type":"array","title":"Allowed Ratios","description":"当前模型允许的比例选项"},"default_ratio":{"type":"string","title":"Default Ratio","description":"当前模型默认比例"}},"type":"object","required":["provider","model_id","model_name","default_ratio"],"title":"VideoGenerationOptionsRead","description":"当前默认视频模型对应的生成参数选项。"},"VideoGenerationTaskRequest":{"properties":{"shot_id":{"type":"string","title":"Shot Id","description":"镜头 ID"},"reference_mode":{"type":"string","enum":["first","last","key","first_last","first_last_key","text_only"],"title":"Reference Mode","description":"参考模式:first | last | key | first_last | first_last_key | text_only"},"prompt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Prompt","description":"视频提示词(text_only 必填)"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"参考图 file_id 列表,数量需与 reference_mode 严格匹配"},"ratio":{"type":"string","enum":["16:9","4:3","1:1","3:4","9:16","21:9"],"title":"Ratio","description":"视频画幅比例,如 16:9 / 9:16"}},"type":"object","required":["shot_id","reference_mode","ratio"],"title":"VideoGenerationTaskRequest","description":"视频生成任务请求。"},"VideoPromptPreviewResponse":{"properties":{"prompt":{"type":"string","title":"Prompt","description":"最终用于视频生成的提示词"},"images":{"items":{"type":"string"},"type":"array","title":"Images","description":"关联参考图 file_id 列表"},"pack":{"anyOf":[{"$ref":"#/components/schemas/ShotVideoPromptPackRead"},{"type":"null"}],"description":"视频提示词预览上下文包"}},"type":"object","required":["prompt"],"title":"VideoPromptPreviewResponse"}}}} \ No newline at end of file diff --git a/front/package.json b/front/package.json index dfafb3ce..09e7e9c8 100644 --- a/front/package.json +++ b/front/package.json @@ -11,6 +11,8 @@ "lint": "eslint . --ext .ts,.tsx", "lint:fix": "eslint . --ext .ts,.tsx --fix", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "openapi:fetch": "curl -sSf http://127.0.0.1:8000/openapi.json -o ./openapi.json", "openapi:gen": "pnpm exec -- openapi --input ./openapi.json --output ./src/services/generated --client fetch --useOptions --useUnionTypes", "openapi:update": "pnpm run openapi:fetch && pnpm run openapi:gen" @@ -29,6 +31,9 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", "@types/react": "^18.2.37", "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.2.15", @@ -39,11 +44,13 @@ "eslint": "^8.54.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^25.0.1", "msw": "^2.6.6", "openapi-typescript-codegen": "^0.30.0", "postcss": "^8.4.32", "tailwindcss": "^3.3.6", "typescript": "^5.2.2", - "vite": "^5.0.8" + "vite": "^5.0.8", + "vitest": "^2.1.8" } } diff --git a/front/pnpm-lock.yaml b/front/pnpm-lock.yaml index a6517ae5..ec9c2969 100644 --- a/front/pnpm-lock.yaml +++ b/front/pnpm-lock.yaml @@ -1,659 +1,465 @@ -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) - -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 +importers: + + .: + dependencies: + '@ant-design/icons': + specifier: ^5.2.6 + version: 5.2.6(react-dom@18.2.0(react@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))(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@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@18.2.0) + react-router-dom: + specifier: ^6.30.3 + version: 6.30.3(react-dom@18.2.0(react@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) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.6.1(@testing-library/dom@10.4.1) + '@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))(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) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + 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 + vitest: + specifier: ^2.1.8 + version: 2.1.9(jsdom@25.0.1)(msw@2.6.6(typescript@5.2.2)) packages: - /@alloc/quick-lru@5.2.0: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@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@1.24.0': resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} peerDependencies: react: '>=16.0.0' react-dom: '>=16.0.0' - 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.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.29.2 - 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): + '@ant-design/icons@5.2.6': resolution: {integrity: sha512-4wn0WShF43TrggskBJPRqCD0fcHbzTYjnaoskdiJrVHg86yxoZ8ZUqsXvyn4WUqehRiFKnaclOhqk9w4Ui2KVw==} 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.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 - /@ant-design/react-slick@1.0.2(react@18.2.0): + '@ant-design/react-slick@1.0.2': resolution: {integrity: sha512-Wj8onxL/T8KQLFFiCA4t8eIRGpRR+UPgOdac2sYzonv+i0n3kXHmvHLLiOYL655DQx2Umii9Y9nNgL7ssu5haQ==} peerDependencies: react: '>=16.9.0' - dependencies: - '@babel/runtime': 7.29.2 - 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 - dependencies: - '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 - dev: true - /@babel/code-frame@7.29.0: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.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: + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - dev: true - /@babel/core@7.29.0: + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.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.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/generator@7.29.1: + '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - dependencies: - '@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: + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: true - /@babel/helper-globals@7.28.0: + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-module-imports@7.28.6: + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - 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): + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - dev: true - /@babel/helper-plugin-utils@7.28.6: + '@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-string-parser@7.27.1: + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-validator-identifier@7.28.5: + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-validator-option@7.27.1: + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helpers@7.29.2: + '@babel/helpers@7.29.2': resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - dev: true - /@babel/parser@7.29.2: + '@babel/parser@7.29.2': resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true - dependencies: - '@babel/types': 7.29.0 - dev: true - /@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0): + '@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 - 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): + '@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 - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - dev: true - /@babel/runtime@7.29.2: + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - dev: false - /@babel/template@7.28.6: + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - dev: true - /@babel/traverse@7.29.0: + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.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: + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.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: + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} - dependencies: - cookie: 0.7.2 - dev: true - /@bundled-es-modules/statuses@1.0.1: + '@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: + '@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: + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@ctrl/tinycolor@3.6.1': resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} engines: {node: '>=10'} - dev: false - /@emotion/hash@0.8.0: + '@emotion/hash@0.8.0': resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} - dev: false - /@emotion/unitless@0.7.5: + '@emotion/unitless@0.7.5': resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} - dev: false - /@esbuild/aix-ppc64@0.19.12: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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: + '@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): + '@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 - dependencies: - eslint: 8.54.0 - eslint-visitor-keys: 3.4.3 - dev: true - /@eslint-community/regexpp@4.12.2: + '@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/eslintrc@2.1.4: + '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - /@eslint/js@8.54.0: + '@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 - /@humanwhocodes/config-array@0.11.14: + '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} deprecated: Use @eslint/config-array instead - 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: + '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - dev: true - /@humanwhocodes/object-schema@2.0.3: + '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - dev: true - /@inquirer/ansi@1.0.2: + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} - dev: true - /@inquirer/confirm@5.1.21: + '@inquirer/confirm@5.1.21': resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} engines: {node: '>=18'} peerDependencies: @@ -661,12 +467,8 @@ packages: peerDependenciesMeta: '@types/node': optional: true - dependencies: - '@inquirer/core': 10.3.2 - '@inquirer/type': 3.0.10 - dev: true - /@inquirer/core@10.3.2: + '@inquirer/core@10.3.2': resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} engines: {node: '>=18'} peerDependencies: @@ -674,23 +476,12 @@ packages: peerDependenciesMeta: '@types/node': optional: true - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10 - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - dev: true - /@inquirer/figures@1.0.15: + '@inquirer/figures@1.0.15': resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} engines: {node: '>=18'} - dev: true - /@inquirer/type@3.0.10: + '@inquirer/type@3.0.10': resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} engines: {node: '>=18'} peerDependencies: @@ -698,504 +489,3074 @@ packages: peerDependenciesMeta: '@types/node': optional: true - dev: true - /@jridgewell/gen-mapping@0.3.13: + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - /@jridgewell/remapping@2.3.5: + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - /@jridgewell/resolve-uri@3.1.2: + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - dev: true - /@jridgewell/sourcemap-codec@1.5.5: + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - dev: true - /@jridgewell/trace-mapping@0.3.31: + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - dev: true - /@mswjs/interceptors@0.37.6: + '@mswjs/interceptors@0.37.6': resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} engines: {node: '>=18'} - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - dev: true - /@nodelib/fs.scandir@2.1.5: + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: true - /@nodelib/fs.stat@2.0.5: + '@nodelib/fs.stat@2.0.5': resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true - /@nodelib/fs.walk@1.2.8: + '@nodelib/fs.walk@1.2.8': resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - dev: true - /@open-draft/deferred-promise@2.2.0: + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - dev: true - /@open-draft/logger@0.3.0: + '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - dev: true - /@open-draft/until@2.1.0: + '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - dev: true - /@rc-component/color-picker@1.4.1(react-dom@18.2.0)(react@18.2.0): + '@rc-component/color-picker@1.4.1': resolution: {integrity: sha512-vh5EWqnsayZa/JwUznqDaPJz39jznx/YDbyBuVJntv735tKXKwEUZZb2jYEldOg+NKWZwtALjGMrNeGBmqFoEw==} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - dependencies: - '@babel/runtime': 7.29.2 - '@ctrl/tinycolor': 3.6.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-component/context@1.4.0(react-dom@18.2.0)(react@18.2.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' - dependencies: - '@babel/runtime': 7.29.2 - 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-component/mini-decimal@1.1.3: + '@rc-component/mini-decimal@1.1.3': resolution: {integrity: sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==} engines: {node: '>=8.x'} - dependencies: - '@babel/runtime': 7.29.2 - dev: false - /@rc-component/mutate-observer@1.1.0(react-dom@18.2.0)(react@18.2.0): + '@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' - dependencies: - '@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-component/portal@1.1.2(react-dom@18.2.0)(react@18.2.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' - dependencies: - '@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-component/tour@1.10.0(react-dom@18.2.0)(react@18.2.0): + '@rc-component/tour@1.10.0': resolution: {integrity: sha512-voV0BKaTJbewB9LLgAHQ7tAGG7rgDkKQkZo82xw2gIk542hY+o7zwoqdN16oHhIKk7eG/xi+mdXrONT62Dt57A==} engines: {node: '>=8.x'} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - dependencies: - '@babel/runtime': 7.29.2 - '@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) - 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-component/trigger@1.18.3(react-dom@18.2.0)(react@18.2.0): + '@rc-component/trigger@1.18.3': resolution: {integrity: sha512-Ksr25pXreYe1gX6ayZ1jLrOrl9OAUHUqnuhEx6MeHnNa1zVM5Y2Aj3Q35UrER0ns8D2cJYtmJtVli+i+4eKrvA==} engines: {node: '>=8.x'} peerDependencies: react: '>=16.9.0' react-dom: '>=16.9.0' - dependencies: - '@babel/runtime': 7.29.2 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) - 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 - /@remix-run/router@1.23.2: + '@remix-run/router@1.23.2': resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} - dev: false - /@rollup/rollup-android-arm-eabi@4.60.1: + '@rollup/rollup-android-arm-eabi@4.60.1': resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] os: [android] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-android-arm64@4.60.1: + '@rollup/rollup-android-arm64@4.60.1': resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] os: [android] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-darwin-arm64@4.60.1: + '@rollup/rollup-darwin-arm64@4.60.1': resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-darwin-x64@4.60.1: + '@rollup/rollup-darwin-x64@4.60.1': resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} cpu: [x64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-freebsd-arm64@4.60.1: + '@rollup/rollup-freebsd-arm64@4.60.1': resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} cpu: [arm64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-freebsd-x64@4.60.1: + '@rollup/rollup-freebsd-x64@4.60.1': resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} cpu: [x64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.60.1: + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm-musleabihf@4.60.1: + '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm64-gnu@4.60.1: + '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-arm64-musl@4.60.1: + '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-loong64-gnu@4.60.1: + '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-loong64-musl@4.60.1: + '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] - requiresBuild: true - dev: true - optional: true - /@rollup/rollup-linux-ppc64-gnu@4.60.1: + '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@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] + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@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/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@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.2.15': + resolution: {integrity: sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==} + + '@types/react-redux@7.1.34': + resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==} + + '@types/react@18.2.37': + resolution: {integrity: sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==} + + '@types/scheduler@0.26.0': + resolution: {integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@typescript-eslint/eslint-plugin@6.13.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/parser@6.13.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/scope-manager@6.13.2': + resolution: {integrity: sha512-CXQA0xo7z6x13FeDYCgBkjWzNqzBn8RXaE3QVQVIUm74fWJLkJkaHmHdKStrxQllGh6Q4eUGyNpMe0b1hMkXFA==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.13.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/types@6.13.2': + resolution: {integrity: sha512-7sxbQ+EMRubQc3wTfTsycgYpSujyVbI1xw+3UMRUcrhSy+pN09y/lWzeKDbvhoqcRbHdc+APLs/PWYi/cisLPg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.13.2': + resolution: {integrity: sha512-SuD8YLQv6WHnOEtKv8D6HZUzOub855cfPnPMKvdM/Bh1plv1f7Q/0iFUDLKKlxHcEstQnaUU4QZskgQq74t+3w==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.13.2': + resolution: {integrity: sha512-b9Ptq4eAZUym4idijCRzl61oPCwwREcfDI8xGk751Vhzig5fFZR9CyzDz4Sp/nxSLBYxUPyh4QdIDqWykFhNmQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.13.2': + resolution: {integrity: sha512-OGznFs0eAQXJsp+xSd6k/O1UbFi/K/L7WjqeRoFE7vadjAF9y0uppXhYNQNEqygjou782maGClOoZwPqF0Drlw==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.2.1': + resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + 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 + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + 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'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + antd@5.10.0: + resolution: {integrity: sha512-qeyfMhcDK2QeuKaC/OCjNCPrJb+3vrBHvVK2swRHsxJvKFSpRerMCShOn/I3CXogrVJazPMluGhy0FQlcHQ4pw==} + 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==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + 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-tree-filter@2.1.0: + resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + 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'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.16: + resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} + 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.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + 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==} + + 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 + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + 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.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + 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@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + 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==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + 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 + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + 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'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + 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'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.334: + resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + 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.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + 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.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + 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.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@7.33.2: + resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + + 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.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 + + 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'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.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@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + 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-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + i18next-browser-languagedetector@7.2.1: + resolution: {integrity: sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==} + + i18next@23.11.4: + resolution: {integrity: sha512-CCUjtd5TfaCl+mLUzAA0uPSN+AVn4fP/kWCYt/hocPUwusTpMVczdrRyOBUwk6N05iH40qiKx6q1DoNJtBIwdg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + 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'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + 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-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + 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 + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: 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@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + 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 + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + 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'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.6.6: + resolution: {integrity: sha512-npfIIVRHKQX3Lw4aLWX4wBh+lQwpqdZNyJYB5K/+ktK8NhtkdsTxGK7WDrgknozcVyRI7TOqY6yBS9j2FTR+YQ==} + 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'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + 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.hasown@1.1.4: + resolution: {integrity: sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg==} + 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'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + 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'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + 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@4.0.2: + 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-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.4.32: + resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qrcode.react@3.2.0: + resolution: {integrity: sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + 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.18.1: + resolution: {integrity: sha512-M7Xr5Fs/E87ZGustfObtBYQjsvBCET0UX2JYXB2GmOP+2fsZgjaRGXK+CJBmmWXQ6o4OFinpBQBXG4wJOQ5MEg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.1.0: + resolution: {integrity: sha512-PAwpJFnBa3Ei+5pyqMMXdcKYKNBMS+TvSDiLdDnARnMJHC8ESxwPfm4Ao1gJiKtWLdmGfigascnCpwrHFgoOBQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.7.3: + resolution: {integrity: sha512-60FJcdTRn0X5sELF18TANwtVi7FtModq649H11mYF1jh83DniMoM4MqY627sEKRCTm4+WXfGDcB7hY5oW6xhyw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.3.4: + resolution: {integrity: sha512-975X3018GhR+EjZFbxA2Z57SX5rnu0G0/OxFgMMvZK4/hQWEm3MHaNvP4wXpxYDoJsp+xUvVW+GB9CMMCm81jA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@6.5.2: + resolution: {integrity: sha512-QckxAnQNdhh4vtmKN0ZwDf3iakO83W9eZcSKWYYTDv4qcD2fHhRAZJJ/OE6v2ZlQ2kSqCJX5gYssF4HJFvsEPQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.1.0: + resolution: {integrity: sha512-VZjMunpBdlVzYpEdJSaV7WM7O0jf8uyDjirxXLZRNZ+tAC+NzD3PXPEtliFwGzVwBBdCmGuSqiS9DWcOLxQ9tw==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@1.38.2: + resolution: {integrity: sha512-O83Oi1qPyEv31Sg+Jwvsj6pXc8uQI2BtIAkURr5lvEYHVggXJhdU/nynK8wY1gbw0qR48k731sN5ON4egRCROA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.3.2: + resolution: {integrity: sha512-ICEF6SWv9YKhDXxy1vrXcmf0TVvEcQWIww5Yg+f+mn7e4oGX7FNP4+FExwMjNO5UHBEuWrigbGhlCgI6yZZ1jg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@8.1.0: + resolution: {integrity: sha512-bdHgduOxuN0lrhzgPmoKbhRD4GLIzVcddVz972/JHPHr7oLwPX5xDb9w4bXhuMzyT2VzQy7nggRCfH3yAl09oA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.2.1: + resolution: {integrity: sha512-nQRmBvEFoGi+SNRDavccZ8ueyhFgmxkWqIt4aDyuNJgUZF12HJKIwDhAafUM7N+g7PyuW9FH3pf3zPHzdiCWbA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.8.0: + resolution: {integrity: sha512-LBMkO6bSGhEvS1CvMK978qGN82tI+mzk7l/uTiQJH+UDiwpvq+pxK4DxU5b6Q1T5LW6bn2pSua9RaZKZrDoBOw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.12.4: + resolution: {integrity: sha512-t2NcvPLV1mFJzw4F21ojOoRVofK2rWhpKPx69q2raUsiHPDP6DDevsBILEYdsIegqBeSXoWs2bf6CueBKg3BFg==} + 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.2.0: + resolution: {integrity: sha512-HwUSypEW4mfOpiakJ7dm6TAKf+3zuSR2xm0I0XMes493rtA3n4EVMvQyldrp23hUwCE3RFj8oncyU1E8iNC4ag==} + 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@3.6.1: + resolution: {integrity: sha512-R/sUnKKXx1Nm4kZfUKS3YKa7yEPF1ZkVB/AynQaHt+nMER7h9wPTfliDJFdYo+RM/nk2JD4Yc5QpUq8fIQHeug==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@3.14.7: + 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-progress@3.5.1: + resolution: {integrity: sha512-V6Amx6SbLRwPin/oD+k1vbPrO8+9Qf8zW1T8A7o83HdNafEVvAxPV5YsgtKFP+Ud5HghLj33zKOcEHrcrUGkfw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.12.0: + resolution: {integrity: sha512-g092v5iZCdVzbjdn28FzvWebK2IutoVoiTeqoLTj9WM7SjA/gOJIw5/JFZMRyJYYVe1jLAU2UhAfstIpCNRozg==} + 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.2.2: + resolution: {integrity: sha512-Mq52M96QdHMsNdE/042ibT5vkcGcD5jxKp7HgPC2SRofpia99P5fkfHy1pEaajLMF/kj0+2Lkq1UZRvqzo9mSA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.9.2: + resolution: {integrity: sha512-VQ15sRFgPURHb8ZcZNSDtb2rAw3+C9xlL0nDziwNHTEW1KvEpZ8y+0v5w24X/Bpl9b3cW1BOyW1F5UqSAq+7Dg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@10.3.1: + resolution: {integrity: sha512-XszsZLkbjcG9ogQy/zUC0n2kndoKUAnY/Vnk1Go5Gx+JJQBz0Tl15d5IfSiglwBUZPS9vsUJZkfCmkIZSqWbcA==} + 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.34.4: + resolution: {integrity: sha512-os+i88Y2AO/6dNkOgJkKSHgXYaZZGnuOEEe+nyaq5IRgvAQNhLysUjXt2objtBeFDEZR8TqXrajwBNRUwunmdw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@12.12.1: + resolution: {integrity: sha512-e10VBjEkECdPl4XZSs9to81SE+mgclBTM7J8/LMsFqmJoi05Tci91bRnmeeDtrcOCx2PuZdJv57XUlC4d8PEIw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.4.0: + resolution: {integrity: sha512-CiqK+uyoJlnfufbC0kwfHJpfElhQacuDSNyNQ/xGnA/QMaJLDbgmqRT8QmX0T0KD/ws/hy6qqRaGJSsrRR5uiQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.1.3: + resolution: {integrity: sha512-HMSbSs5oieZ7XddtINUddBLSVgsnlaSb3bZrzzGWjXa7/B7nNedmsuz72s7EWFEro9mNa7RyF3gOXKYqvJiTcQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.13.0: + resolution: {integrity: sha512-g01JU9EdE7j/9KfDKtmvFqJ7ZDNIYDzkpmAXllbTBFoRNhWJBjW1x/dCZLVG+IdZeIz8SKJkgZzCf1CUZrzV/Q==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.7.12: + resolution: {integrity: sha512-LXA5nY2hG5koIAlHW5sgXgLpOMz+bFRbnZZ+cCg0tQs4Wv1AmY7EDi1SK7iFXhslYockbqUerQan82jljoaItg==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.3.6: + resolution: {integrity: sha512-Bt7ESeG5tT3IY82fZcP+s0tQU2xmo1W6P3S8NboUUliquJLQYLkUcsaExi3IlBVr43GQMCjo30RA2o0i70+NjA==} + 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.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + + react-i18next@15.0.1: + 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-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.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + 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.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + 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'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + 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'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + 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 + + 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 + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + 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'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + 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.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + 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'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + 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'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + 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.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-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + 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'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@3.3.6: + resolution: {integrity: sha512-AKjF7qbbLvLaPieoKeTjG1+FyNZT6KaJMJPFeQyLfIp7l82ggH1fbHJSsYIvnbTFQOlkh+gBYpyby5GT1LIdLw==} + 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==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + 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@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + 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@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + 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.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + 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@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + 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==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + 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-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: 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 + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + 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 + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + 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==} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + 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.11: + 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 + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/cssinjs@1.24.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + 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.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + 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.2.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + 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.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@ant-design/react-slick@1.0.2(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 18.2.0 + 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 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.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.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@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 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@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 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.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 + + '@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 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.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.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bundled-es-modules/cookie@2.0.1': + dependencies: + cookie: 0.7.2 + + '@bundled-es-modules/statuses@1.0.1': + dependencies: + statuses: 2.0.2 + + '@bundled-es-modules/tough-cookie@0.1.6': + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@ctrl/tinycolor@3.6.1': {} + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.54.0)': + dependencies: + eslint: 8.54.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.54.0': {} + + '@humanwhocodes/config-array@0.11.14': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/confirm@5.1.21': + dependencies: + '@inquirer/core': 10.3.2 + '@inquirer/type': 3.0.10 + + '@inquirer/core@10.3.2': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/type@3.0.10': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mswjs/interceptors@0.37.6': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@rc-component/color-picker@1.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@ctrl/tinycolor': 3.6.1 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@rc-component/context@1.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + rc-util: 5.44.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@rc-component/mini-decimal@1.1.3': + dependencies: + '@babel/runtime': 7.29.2 + + '@rc-component/mutate-observer@1.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@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: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@rc-component/portal@1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@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: 18.2.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))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.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))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@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: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@remix-run/router@1.23.2': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true - /@rollup/rollup-linux-ppc64-musl@4.60.1: - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} - cpu: [ppc64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-android-arm64@4.60.1': optional: true - /@rollup/rollup-linux-riscv64-gnu@4.60.1: - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-darwin-arm64@4.60.1': optional: true - /@rollup/rollup-linux-riscv64-musl@4.60.1: - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} - cpu: [riscv64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-darwin-x64@4.60.1': optional: true - /@rollup/rollup-linux-s390x-gnu@4.60.1: - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-freebsd-arm64@4.60.1': optional: true - /@rollup/rollup-linux-x64-gnu@4.60.1: - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} - cpu: [x64] - os: [linux] - libc: [glibc] - requiresBuild: true - dev: true + '@rollup/rollup-freebsd-x64@4.60.1': optional: true - /@rollup/rollup-linux-x64-musl@4.60.1: - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} - cpu: [x64] - os: [linux] - libc: [musl] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': optional: true - /@rollup/rollup-openbsd-x64@4.60.1: - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm-musleabihf@4.60.1': optional: true - /@rollup/rollup-openharmony-arm64@4.60.1: - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} - cpu: [arm64] - os: [openharmony] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm64-gnu@4.60.1': optional: true - /@rollup/rollup-win32-arm64-msvc@4.60.1: - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true - /@rollup/rollup-win32-ia32-msvc@4.60.1: - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - /@rollup/rollup-win32-x64-gnu@4.60.1: - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true - /@rollup/rollup-win32-x64-msvc@4.60.1: - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - /@types/babel__core@7.20.5: - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.37 + '@types/react-dom': 18.2.15 + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': dependencies: '@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.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/cookie@0.6.0': {} - /@types/estree@1.0.8: - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - dev: true + '@types/estree@1.0.8': {} - /@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.2.37)': dependencies: '@types/react': 18.2.37 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-dom@18.2.15: - resolution: {integrity: sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==} + '@types/react-dom@18.2.15': dependencies: '@types/react': 18.2.37 - dev: true - /@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 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.2.37': 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/scheduler@0.26.0': {} - /@types/semver@7.7.1: - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - dev: true + '@types/semver@7.7.1': {} - /@types/statuses@2.0.6: - resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - dev: true + '@types/statuses@2.0.6': {} - /@types/tough-cookie@4.0.5: - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - dev: true + '@types/tough-cookie@4.0.5': {} - /@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.13.2(@typescript-eslint/parser@6.13.2(eslint@8.54.0)(typescript@5.2.2))(eslint@8.54.0)(typescript@5.2.2)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 6.13.2(eslint@8.54.0)(typescript@5.2.2) @@ -1210,20 +3571,12 @@ packages: natural-compare: 1.4.0 semver: 7.7.4 ts-api-utils: 1.4.3(typescript@5.2.2) + optionalDependencies: typescript: 5.2.2 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.13.2(eslint@8.54.0)(typescript@5.2.2)': dependencies: '@typescript-eslint/scope-manager': 6.13.2 '@typescript-eslint/types': 6.13.2 @@ -1231,52 +3584,31 @@ packages: '@typescript-eslint/visitor-keys': 6.13.2 debug: 4.4.3 eslint: 8.54.0 + optionalDependencies: typescript: 5.2.2 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.13.2': dependencies: '@typescript-eslint/types': 6.13.2 '@typescript-eslint/visitor-keys': 6.13.2 - dev: true - /@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.13.2(eslint@8.54.0)(typescript@5.2.2)': 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) debug: 4.4.3 eslint: 8.54.0 ts-api-utils: 1.4.3(typescript@5.2.2) + optionalDependencies: typescript: 5.2.2 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.13.2': {} - /@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.13.2(typescript@5.2.2)': dependencies: '@typescript-eslint/types': 6.13.2 '@typescript-eslint/visitor-keys': 6.13.2 @@ -1285,16 +3617,12 @@ packages: is-glob: 4.0.3 semver: 7.7.4 ts-api-utils: 1.4.3(typescript@5.2.2) + optionalDependencies: typescript: 5.2.2 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.13.2(eslint@8.54.0)(typescript@5.2.2)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.54.0) '@types/json-schema': 7.0.15 @@ -1307,25 +3635,15 @@ packages: 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.13.2': dependencies: '@typescript-eslint/types': 6.13.2 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.2.1(vite@5.0.8)': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -1335,95 +3653,119 @@ packages: vite: 5.0.8 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 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(msw@2.6.6(typescript@5.2.2))(vite@5.0.8)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.6.6(typescript@5.2.2) + vite: 5.0.8 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.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==} + agent-base@7.1.4: {} + + 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' + ansi-styles@5.2.0: {} + + antd@5.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): 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/cssinjs': 1.24.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@ant-design/icons': 5.2.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@ant-design/react-slick': 1.0.2(react@18.2.0) '@babel/runtime': 7.29.2 '@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) + '@rc-component/color-picker': 1.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@rc-component/mutate-observer': 1.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@rc-component/tour': 1.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) classnames: 2.5.1 copy-to-clipboard: 3.3.3 dayjs: 1.11.20 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.20)(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) + rc-cascader: 3.18.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-checkbox: 3.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-collapse: 3.7.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-dialog: 9.3.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-drawer: 6.5.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-dropdown: 4.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-field-form: 1.38.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-image: 7.3.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-input: 1.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-input-number: 8.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-mentions: 2.8.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-menu: 9.12.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-motion: 2.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-notification: 5.2.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-pagination: 3.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-picker: 3.14.7(dayjs@1.11.20)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-progress: 3.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-rate: 2.12.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-segmented: 2.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-select: 14.9.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-slider: 10.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-steps: 6.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-switch: 4.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-table: 7.34.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-tabs: 12.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-textarea: 1.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-tooltip: 6.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-tree: 5.7.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-tree-select: 5.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-upload: 4.3.6(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) scroll-into-view-if-needed: 3.1.0 @@ -1432,39 +3774,30 @@ packages: - 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.2 - dev: true - /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'} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + 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.9 call-bound: 1.0.4 @@ -1474,51 +3807,34 @@ 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-tree-filter@2.1.0: {} - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: true + array-union@2.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.9 define-properties: 1.2.1 es-abstract: 1.24.2 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.9 define-properties: 1.2.1 es-abstract: 1.24.2 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.9 define-properties: 1.2.1 es-abstract: 1.24.2 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.9 @@ -1527,27 +3843,16 @@ 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 + assertion-error@2.0.1: {} - /async-validator@4.2.5: - resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - dev: false + async-function@1.0.0: {} - /asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - dev: false + async-validator@4.2.5: {} - /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 + asynckit@0.4.0: {} + + autoprefixer@10.4.16(postcss@8.4.32): dependencies: browserslist: 4.28.2 caniuse-lite: 1.0.30001787 @@ -1556,121 +3861,85 @@ packages: picocolors: 1.1.1 postcss: 8.4.32 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.13.6: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 proxy-from-env: 1.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.17: - resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} - engines: {node: '>=6.0.0'} - hasBin: true - dev: true + baseline-browser-mapping@2.10.17: {} - /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.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + 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'} + braces@3.0.3: dependencies: - fill-range: 7.1.1 - dev: true - - /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 + fill-range: 7.1.1 + + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.17 caniuse-lite: 1.0.30001787 electron-to-chromium: 1.5.334 node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - dev: true - /call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - /call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} + call-bind@1.0.9: 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.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} - dev: true + caniuse-lite@1.0.30001787: {} - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + 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'} + check-error@2.1.3: {} + + chokidar@3.6.0: dependencies: anymatch: 3.1.3 braces: 3.0.3 @@ -1681,219 +3950,148 @@ 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@0.7.2: {} - /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 + css.escape@1.5.1: {} - /csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cssesc@3.0.0: {} - /data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + 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.20: - resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} - 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 + decimal.js@10.6.0: {} - /define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + deep-eql@5.0.2: {} + + deep-is@0.1.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 + dequal@2.0.3: {} - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + didyoumean@1.2.2: {} + + 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'} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + 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.334: - resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} - dev: true + electron-to-chromium@1.5.334: {} - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true + emoji-regex@8.0.0: {} - /es-abstract@1.24.2: - resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} - engines: {node: '>= 0.4'} + entities@6.0.1: {} + + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 @@ -1949,19 +4147,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.3.2: - resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} - engines: {node: '>= 0.4'} + es-iterator-helpers@1.3.2: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -1979,44 +4170,31 @@ packages: internal-slot: 1.1.0 iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - dev: true - /es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: {} + + 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.19.12: optionalDependencies: '@esbuild/aix-ppc64': 0.19.12 '@esbuild/android-arm': 0.19.12 @@ -2041,32 +4219,16 @@ packages: '@esbuild/win32-arm64': 0.19.12 '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 - dev: true - /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.0(eslint@8.54.0): dependencies: eslint: 8.54.0 - dev: true - /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.33.2(eslint@8.54.0): dependencies: array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 @@ -2085,26 +4247,15 @@ packages: resolve: 2.0.0-next.6 semver: 6.3.1 string.prototype.matchall: 4.0.12 - dev: true - /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.54.0: dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.54.0) '@eslint-community/regexpp': 4.12.2 @@ -2146,176 +4297,104 @@ 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 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true + esutils@2.0.3: {} - /fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + 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.4): - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - dependencies: + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: picomatch: 4.0.4 - dev: true - /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.4.2 keyv: 4.5.4 rimraf: 3.0.2 - dev: true - /flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - 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@4.3.7: {} - /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.9 call-bound: 1.0.4 @@ -2323,30 +4402,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 +4424,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 +4451,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 +4469,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.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - dev: true + graphql@16.13.2: {} - /handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -2456,329 +4486,218 @@ 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-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + i18next-browser-languagedetector@7.2.1: dependencies: - void-elements: 3.1.0 - dev: false + '@babel/runtime': 7.29.2 - /i18next-browser-languagedetector@7.2.1: - resolution: {integrity: sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==} + i18next@23.11.4: dependencies: '@babel/runtime': 7.29.2 - dev: false - /i18next@23.11.4: - resolution: {integrity: sha512-CCUjtd5TfaCl+mLUzAA0uPSN+AVn4fP/kWCYt/hocPUwusTpMVczdrRyOBUwk6N05iH40qiKx6q1DoNJtBIwdg==} + iconv-lite@0.6.3: dependencies: - '@babel/runtime': 7.29.2 - dev: false + safer-buffer: 2.1.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. + indent-string@4.0.0: {} + + 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.9 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-potential-custom-element-name@1.0.1: {} + + 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,179 +4705,137 @@ 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 + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate - /json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true + jsesc@3.1.0: {} - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true + json-buffer@3.0.1: {} - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - dev: true + json-schema-traverse@0.4.1: {} - /json2mq@0.2.0: - resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + json-stable-stringify-without-jsonify@1.0.1: {} + + 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@2.1.0: {} - /lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - dev: true + lilconfig@3.1.3: {} - /lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true + lines-and-columns@1.2.4: {} - /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==} + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + 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'} + lz-string@1.5.0: {} - /memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - dev: false + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: true + math-intrinsics@1.1.0: {} - /micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + memoize-one@5.2.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.2 - dev: true - /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==} + min-indent@1.0.1: {} + + minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 - dev: true - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true + minimist@1.2.8: {} - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true + ms@2.1.3: {} - /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 + msw@2.6.6(typescript@5.2.2): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 @@ -2977,85 +4854,50 @@ packages: path-to-regexp: 6.3.0 strict-event-emitter: 0.5.1 type-fest: 4.41.0 - typescript: 5.2.2 yargs: 17.7.2 + optionalDependencies: + typescript: 5.2.2 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.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - 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 + normalize-range@0.1.2: {} - /object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + nwsapi@2.2.24: {} - /object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - dev: true + object-assign@4.1.1: {} - /object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - dev: true + object-hash@3.0.0: {} - /object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - dev: true + object-inspect@1.13.4: {} - /object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} + object-keys@1.1.1: {} + + object.assign@4.1.7: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -3063,56 +4905,39 @@ 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.9 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.9 define-properties: 1.2.1 es-abstract: 1.24.2 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'} + object.hasown@1.1.4: dependencies: define-properties: 1.2.1 es-abstract: 1.24.2 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.9 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 @@ -3121,11 +4946,8 @@ packages: 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,732 +4955,446 @@ 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 + parse5@7.3.0: + dependencies: + entities: 6.0.1 - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: true + path-exists@4.0.0: {} - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: true + path-is-absolute@1.0.1: {} - /path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true + path-key@3.1.1: {} - /path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - dev: true + path-parse@1.0.7: {} - /path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - dev: true + path-to-regexp@6.3.0: {} - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - dev: true + path-type@4.0.0: {} - /picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - dev: true + pathe@1.1.2: {} - /picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - dev: true + pathval@2.0.1: {} - /pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - dev: true + picocolors@1.1.1: {} - /pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - dev: true + picomatch@2.3.2: {} - /possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - dev: true + picomatch@4.0.4: {} - /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 + pify@2.3.0: {} + + pirates@4.0.7: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.4.32): dependencies: postcss: 8.4.32 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.4.32): dependencies: camelcase-css: 2.0.1 postcss: 8.4.32 - dev: true - /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@4.0.2(postcss@8.4.32): dependencies: lilconfig: 3.1.3 - postcss: 8.4.32 yaml: 2.8.3 - dev: true + optionalDependencies: + postcss: 8.4.32 - /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.4.32): dependencies: postcss: 8.4.32 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.4.32: 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: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 - /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@1.1.0: {} - /psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + psl@1.15.0: dependencies: punycode: 2.3.1 - dev: true - /punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - dev: true + punycode@2.3.1: {} - /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 + qrcode.react@3.2.0(react@18.2.0): dependencies: react: 18.2.0 - dev: false - /querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - dev: true + querystringify@2.2.0: {} - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true + queue-microtask@1.2.3: {} - /raf-schd@4.0.3: - resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} - dev: false + raf-schd@4.0.3: {} - /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' + rc-cascader@3.18.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 array-tree-filter: 2.1.0 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) + rc-select: 14.9.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-tree: 5.7.12(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.7.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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-motion: 2.9.5(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.3.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@rc-component/portal': 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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-motion: 2.9.5(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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@6.5.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@rc-component/portal': 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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-motion: 2.9.5(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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@1.38.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 async-validator: 4.2.5 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.3.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/portal': 1.1.2(react-dom@18.2.0)(react@18.2.0) + '@rc-component/portal': 1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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) + rc-dialog: 9.3.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-motion: 2.9.5(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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@8.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-input: 1.2.1(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.8.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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) + rc-input: 1.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-menu: 9.12.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-textarea: 1.4.0(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.12.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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-motion: 2.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-overflow: 1.5.0(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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-motion: 2.9.5(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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@3.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /rc-picker@3.14.7(dayjs@1.11.20)(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@3.14.7(dayjs@1.11.20)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) classnames: 2.5.1 - dayjs: 1.11.20 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false + 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@3.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.12.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) 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.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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-motion: 2.9.5(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.9.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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) + rc-motion: 2.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-overflow: 1.5.0(react-dom@18.2.0(react@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) + rc-virtual-list: 3.19.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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@10.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.34.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/context': 1.4.0(react-dom@18.2.0)(react@18.2.0) + '@rc-component/context': 1.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 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) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@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) + rc-virtual-list: 3.19.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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@12.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-dropdown: 4.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-menu: 9.12.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-motion: 2.9.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-input: 1.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 - '@rc-component/trigger': 1.18.3(react-dom@18.2.0)(react@18.2.0) + '@rc-component/trigger': 1.18.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) classnames: 2.5.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.13.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-select: 14.9.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + rc-tree: 5.7.12(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.7.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-motion: 2.9.5(react-dom@18.2.0(react@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) + rc-virtual-list: 3.19.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.3.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 classnames: 2.5.1 - rc-util: 5.44.4(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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) 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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@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) + rc-resize-observer: 1.4.3(react-dom@18.2.0(react@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: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 css-box-model: 1.2.1 @@ -3866,65 +5402,34 @@ packages: 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-redux: 7.2.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0) redux: 4.2.1 use-memo-one: 1.1.3(react@18.2.0) 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.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 react: 18.2.0 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.0.1(i18next@23.11.4)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 i18next: 23.11.4 react: 18.2.0 + optionalDependencies: react-dom: 18.2.0(react@18.2.0) - dev: false - /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.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.29.2 '@types/react-redux': 7.1.34 @@ -3932,67 +5437,46 @@ packages: loose-envify: 1.4.0 prop-types: 15.8.1 react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) react-is: 17.0.2 - dev: false + optionalDependencies: + react-dom: 18.2.0(react@18.2.0) - /react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} - engines: {node: '>=0.10.0'} - dev: true + react-refresh@0.14.2: {} - /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.2.0(react@18.2.0))(react@18.2.0): 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-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.2.0): dependencies: '@remix-run/router': 1.23.2 react: 18.2.0 - dev: false - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} - engines: {node: '>=0.10.0'} + react@18.2.0: 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.2 - dev: true - /redux@4.2.1: - resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + redux@4.2.1: dependencies: '@babel/runtime': 7.29.2 - dev: false - /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.9 define-properties: 1.2.1 @@ -4002,11 +5486,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.9 define-properties: 1.2.1 @@ -4014,40 +5495,22 @@ 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 + requires-port@1.0.0: {} - /resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - dev: false + resize-observer-polyfill@1.5.1: {} - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - dev: true + resolve-from@4.0.0: {} - /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,25 +5518,14 @@ 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 + reusify@1.1.0: {} - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true + rimraf@3.0.2: dependencies: glob: 7.2.3 - dev: true - /rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: @@ -4103,68 +5555,53 @@ packages: '@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==} + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + 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.9 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==} + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + 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 +5609,86 @@ 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.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} + side-channel-list@1.0.1: 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.1 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 + siginfo@2.0.0: {} - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: true + signal-exit@4.1.0: {} - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - dev: true + slash@3.0.0: {} - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - dev: true + source-map-js@1.2.1: {} - /statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - dev: true + source-map@0.6.1: {} - /stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + 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.9 call-bound: 1.0.4 @@ -4312,11 +5703,8 @@ 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.trim@1.2.10: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -4325,47 +5713,33 @@ packages: es-abstract: 1.24.2 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.9 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.9 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-indent@3.0.0: + dependencies: + min-indent: 1.0.1 - /stylis@4.3.6: - resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} - dev: false + strip-json-comments@3.1.1: {} - /sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true + stylis@4.3.6: {} + + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 @@ -4374,24 +5748,16 @@ packages: pirates: 4.0.7 tinyglobby: 0.2.16 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 + symbol-tree@3.2.4: {} + + tailwindcss@3.3.6: dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -4417,116 +5783,92 @@ packages: sucrase: 3.35.1 transitivePeerDependencies: - ts-node - dev: true - /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.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - dev: true - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + 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@4.1.4: dependencies: psl: 1.15.0 punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 - dev: true - /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' + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@1.4.3(typescript@5.2.2): dependencies: typescript: 5.2.2 - dev: true - /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@4.41.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.9 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.9 @@ -4535,11 +5877,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.9 for-each: 0.3.5 @@ -4547,132 +5886,131 @@ 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.2.2: {} - /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@0.2.0: {} - /universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - dev: true + universalify@2.0.1: {} - /update-browserslist-db@1.2.3(browserslist@4.28.2): - 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.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==} + url-parse@1.5.10: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 - dev: true - /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 + use-memo-one@1.1.3(react@18.2.0): dependencies: react: 18.2.0 - dev: false - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true + util-deprecate@1.0.2: {} - /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 + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.0.8 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + + vite@5.0.8: dependencies: esbuild: 0.19.12 postcss: 8.4.32 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 + vitest@2.1.9(jsdom@25.0.1)(msw@2.6.6(typescript@5.2.2)): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(msw@2.6.6(typescript@5.2.2))(vite@5.0.8) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.0.8 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - stylus + - sugarss + - supports-color + - terser - /which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} + void-elements@3.1.0: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + 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 +6025,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.9 @@ -4710,70 +6042,49 @@ 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 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 - /wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - dev: true + word-wrap@1.2.5: {} - /wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} + wordwrap@1.0.0: {} + + 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 + ws@8.21.1: {} - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: true + xml-name-validator@5.0.0: {} - /yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true - dev: true + xmlchars@2.2.0: {} - /yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - dev: true + y18n@5.0.8: {} - /yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + yallist@3.1.1: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: dependencies: cliui: 8.0.1 escalade: 3.2.0 @@ -4782,36 +6093,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: + zustand@5.0.11(@types/react@18.2.37)(react@18.2.0): + optionalDependencies: '@types/react': 18.2.37 react: 18.2.0 - dev: false diff --git a/front/src/App.tsx b/front/src/App.tsx index 69bccf86..0532364e 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -19,6 +19,7 @@ import AgentManagement from './pages/aiStudio/agents/AgentManagement' import AgentEdit from './pages/aiStudio/agents/AgentEdit.tsx' import ModelManagement from './pages/aiStudio/models/ModelManagement' import { ChapterShotsPage } from './pages/aiStudio/shots/ChapterShotsPage' +import Ep001Workspace from './pages/aiStudio/cas/Ep001Workspace' import { ChapterShotEditPage } from './pages/aiStudio/shots/ChapterShotEditPage' import './App.css' @@ -35,6 +36,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/front/src/pages/aiStudio/cas/Ep001Workspace.test.tsx b/front/src/pages/aiStudio/cas/Ep001Workspace.test.tsx new file mode 100644 index 00000000..c923b2a9 --- /dev/null +++ b/front/src/pages/aiStudio/cas/Ep001Workspace.test.tsx @@ -0,0 +1,365 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { UserEvent } from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +// 顶层 type-only 导入:运行时被擦除,因此不影响 vi.mock 的提升行为, +// 同时避免 consistent-type-imports 禁止的 `typeof import(...)` 内联注解。 +import type * as CasWorkspaceApi from '../../../services/casWorkspaceApi' + +vi.mock('../../../services/casWorkspaceApi', async () => { + const actual = await vi.importActual( + '../../../services/casWorkspaceApi', + ) + return { + ...actual, + fetchChapter: vi.fn(), + fetchShotBundles: vi.fn(), + fetchSubtitleFiles: vi.fn(), + fetchSubtitleText: vi.fn(), + fetchTaskStatus: vi.fn(), + fetchTaskResult: vi.fn(), + startAsyncImport: vi.fn(), + } +}) + +import * as api from '../../../services/casWorkspaceApi' +import Ep001Workspace from './Ep001Workspace' + +const CHAPTER = { + id: 'ch-1', + project_id: 'proj-1', + title: 'BTC Breaks Out — Bruno Celebrates Too Early', + summary: 'Bruno celebrates the breakout too early.', + storyboard_count: 4, + status: 'draft', +} + +/** fetchShotBundles 已按 index 排序;这里保持已排序形态,顺序断言仍验证渲染顺序。 */ +function bundle( + id: string, + index: number, + title: string, + duration: number, + text: string, + speaker: string, +) { + return { + shot: { id, chapter_id: 'ch-1', index, title, status: 'pending', script_excerpt: '' }, + detail: { id, duration, camera_shot: 'MEDIUM', angle: 'EYE_LEVEL', movement: 'STATIC' }, + dialogLines: [{ id: index, shot_detail_id: id, index: 1, text, speaker_name: speaker }], + } +} + +const SHOTS = [ + bundle('s1', 1, 'The premature toast', 3, 'Breakout! We are so back!', 'Bruno Bull'), + bundle('s2', 2, 'Confirmation, please', 7, "The candle hasn't closed yet.", 'Boris Bear'), + bundle('s3', 3, 'The dip', 7, "It's still green… right?", 'Bruno Bull'), + bundle('s4', 4, 'Before the close', 5, 'Your confetti arrives before candle close.', 'Milo Cat'), +] + +const SUBTITLE_FILE = { + id: 'file-sub-1', + name: 'CAS-EP001.zh-Hant.vtt', + type: 'subtitle' as const, + tags: ['cas', 'subtitle', 'zh-Hant'], +} + +const VTT = `WEBVTT +Language: zh-Hant + +NOTE cue=1 shot=SC01 +c1 +00:00:00.400 --> 00:00:02.000 +突破了!我們回來了! +` + +function renderPage() { + return render( + + + } /> + + , + ) +} + +/** 轮询间隔,与组件内 POLL_INTERVAL_MS 保持一致。 */ +const POLL_INTERVAL_MS = 2000 + +/** + * 填写导入表单。 + * + * JSON 里的 `{` 会被 user-event 当作键盘描述符(如 `{enter}`),因此正文一律用 + * click + paste 输入字面量;不为了迁就测试而去转义产品输入或改动 JSON 解析。 + */ +async function fillImportForm(user: UserEvent, json: string, key = 'k1') { + const keyInput = screen.getByLabelText('idempotency_key') + await user.click(keyInput) + await user.paste(key) + + const jsonInput = screen.getByLabelText('Episode Package JSON') + await user.click(jsonInput) + await user.paste(json) +} + +/** + * 假定时器下的表单填写与提交。 + * + * 不用 user-event:它的每一次交互内部都要 await 自己的 setTimeout,在假定时器下 + * 依赖 antd/rc-util 排入的 rAF 与延时被推进,容易挂起到测试超时。fireEvent 是同步的、 + * 不涉及定时器,因此在假定时器场景下更可靠。这是测试手法调整,产品代码未改。 + */ +function fillAndSubmitSync(json: string, key = 'k1') { + fireEvent.change(screen.getByLabelText('idempotency_key'), { target: { value: key } }) + fireEvent.change(screen.getByLabelText('Episode Package JSON'), { target: { value: json } }) + fireEvent.click(screen.getByTestId('import-submit')) +} + +beforeEach(() => { + vi.mocked(api.fetchChapter).mockResolvedValue(CHAPTER as never) + vi.mocked(api.fetchShotBundles).mockResolvedValue(SHOTS as never) + vi.mocked(api.fetchSubtitleFiles).mockResolvedValue([SUBTITLE_FILE] as never) + vi.mocked(api.fetchSubtitleText).mockResolvedValue(VTT) +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('Ep001Workspace', () => { + it('renders the episode summary once loaded', async () => { + renderPage() + await waitFor(() => expect(screen.getByTestId('ep001-workspace')).toBeInTheDocument()) + expect(screen.getByText(/BTC Breaks Out/)).toBeInTheDocument() + expect(screen.getByText('ch-1')).toBeInTheDocument() + expect(screen.getByText('proj-1')).toBeInTheDocument() + }) + + it('lists exactly four shots in stored sequence order', async () => { + renderPage() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + const rows = screen.getAllByTestId('shot-row') + expect(rows).toHaveLength(4) + expect(rows.map((r) => r.textContent?.trim())).toEqual([ + '#1 The premature toast', + '#2 Confirmation, please', + '#3 The dip', + '#4 Before the close', + ]) + }) + + it('renders English dialogue with its speaker', async () => { + const user = userEvent.setup() + renderPage() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + await user.click(screen.getAllByTestId('shot-row')[0]) + await waitFor(() => { + expect(screen.getByText(/Breakout! We are so back!/)).toBeInTheDocument() + }) + expect(screen.getByText(/Bruno Bull/)).toBeInTheDocument() + }) + + it('shows subtitle artifact metadata and a download action', async () => { + renderPage() + await waitFor(() => expect(screen.getByTestId('subtitle-meta')).toBeInTheDocument()) + const panel = screen.getByTestId('subtitle-panel') + expect(within(panel).getByText('subtitle')).toBeInTheDocument() + expect(within(panel).getByText('text/vtt')).toBeInTheDocument() + expect(within(panel).getByText('file-sub-1')).toBeInTheDocument() + // 直接取属性再断言,避免依赖 jest-dom 对非对称匹配器的支持差异。 + const href = screen.getByTestId('subtitle-download').getAttribute('href') ?? '' + expect(href).toContain('/api/v1/studio/files/file-sub-1/download') + }) + + it('previews WebVTT cues as escaped text, never as markup', async () => { + vi.mocked(api.fetchSubtitleText).mockResolvedValue( + 'WEBVTT\n\nc1\n00:00:00.000 --> 00:00:01.000\n\n', + ) + renderPage() + await waitFor(() => expect(screen.getByTestId('subtitle-preview')).toBeInTheDocument()) + expect(screen.getByText('')).toBeInTheDocument() + // 字幕内容没有变成真实 DOM 元素 + expect(document.querySelector('img')).toBeNull() + }) + + it('shows an empty state when no subtitle artifact exists', async () => { + vi.mocked(api.fetchSubtitleFiles).mockResolvedValue([]) + renderPage() + await waitFor(() => expect(screen.getByTestId('subtitle-panel')).toBeInTheDocument()) + expect(screen.getByText(/尚未生成字幕产物/)).toBeInTheDocument() + }) + + it('renders a subtitle error state with a retry action', async () => { + vi.mocked(api.fetchSubtitleFiles).mockRejectedValue(new Error('boom')) + renderPage() + await waitFor(() => expect(screen.getByText('字幕读取失败')).toBeInTheDocument()) + // antd Button 对「恰好两个中文字符」会自动插入一个空格(ConfigProvider + // autoInsertSpace 默认开启),DOM 里实际是「重 试」而不是「重试」。 + // 因此用允许空白的正则匹配,而不是精确字符串或可访问名称。 + const panel = screen.getByTestId('subtitle-panel') + expect(within(panel).getByText(/重\s*试/)).toBeInTheDocument() + }) + + it('renders a workspace load error', async () => { + vi.mocked(api.fetchChapter).mockRejectedValue(new Error('chapter exploded') as never) + renderPage() + await waitFor(() => expect(screen.getByText('工作台加载失败')).toBeInTheDocument()) + expect(screen.getByText('chapter exploded')).toBeInTheDocument() + }) + + it('rejects malformed import JSON client-side without calling the API', async () => { + const user = userEvent.setup() + renderPage() + await waitFor(() => expect(screen.getByTestId('import-panel')).toBeInTheDocument()) + await fillImportForm(user, '{ not json') + await user.click(screen.getByTestId('import-submit')) + await waitFor(() => expect(screen.getByTestId('import-error')).toBeInTheDocument()) + expect(api.startAsyncImport).not.toHaveBeenCalled() + }) + + it('surfaces a backend import error', async () => { + const user = userEvent.setup() + vi.mocked(api.startAsyncImport).mockRejectedValue({ body: { message: 'QA gate failed' } }) + renderPage() + await waitFor(() => expect(screen.getByTestId('import-panel')).toBeInTheDocument()) + await fillImportForm(user, '{"a":1}') + await user.click(screen.getByTestId('import-submit')) + await waitFor(() => expect(screen.getByText('QA gate failed')).toBeInTheDocument()) + }) + + it('polls a pending task through to success and refreshes the workspace', async () => { + vi.useFakeTimers() + vi.mocked(api.startAsyncImport).mockResolvedValue({ + task_id: 't1', + status: 'pending', + reused: false, + task_kind: 'cas_import_episode_package', + relation_type: 'cas_episode_import', + relation_entity_id: 'x'.repeat(64), + }) + vi.mocked(api.fetchTaskStatus) + .mockResolvedValueOnce({ id: 't1', status: 'running' }) + .mockResolvedValue({ id: 't1', status: 'succeeded' }) + vi.mocked(api.fetchTaskResult).mockResolvedValue({ + status: 'imported', + chapter_id: 'ch-1', + subtitle_artifacts: [ + { + file_id: 'file-sub-1', + language_tag: 'zh-Hant', + storage_key: 'cas/subtitles/proj-1/CAS-EP001/zh-Hant.vtt', + cue_count: 4, + byte_size: 321, + created: true, + }, + ], + } as never) + + renderPage() + // 冲刷首屏加载的 mock promise(不推进定时器) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('import-panel')).toBeInTheDocument() + + fillAndSubmitSync('{"a":1}') + + // 提交与首次轮询都是已解决的 promise,冲刷微任务即可 + await vi.advanceTimersByTimeAsync(0) + expect(api.fetchTaskStatus).toHaveBeenCalledTimes(1) + expect(api.fetchTaskResult).not.toHaveBeenCalled() + + // 推进一个轮询周期 → 第二次轮询返回 succeeded + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS) + expect(api.fetchTaskStatus).toHaveBeenCalledTimes(2) + expect(api.fetchTaskResult).toHaveBeenCalledWith('t1') + // 成功后用返回的导入结果刷新工作台(最小必要的异步断言) + await vi.waitFor(() => expect(screen.getByTestId('import-result')).toBeInTheDocument()) + expect(vi.mocked(api.fetchChapter).mock.calls.length).toBeGreaterThan(1) + + // 终态之后不再轮询 + const callsAfterSuccess = vi.mocked(api.fetchTaskStatus).mock.calls.length + await vi.advanceTimersByTimeAsync(10 * POLL_INTERVAL_MS) + expect(vi.mocked(api.fetchTaskStatus).mock.calls.length).toBe(callsAfterSuccess) + }) + + it('shows a failed task with its error detail', async () => { + const user = userEvent.setup() + vi.mocked(api.startAsyncImport).mockResolvedValue({ + task_id: 't2', + status: 'pending', + reused: false, + task_kind: 'cas_import_episode_package', + relation_type: 'cas_episode_import', + relation_entity_id: 'y'.repeat(64), + }) + vi.mocked(api.fetchTaskStatus).mockResolvedValue({ + id: 't2', + status: 'failed', + error: 'Project not found: nope', + }) + + renderPage() + await waitFor(() => expect(screen.getByTestId('import-panel')).toBeInTheDocument()) + await fillImportForm(user, '{"a":1}') + await user.click(screen.getByTestId('import-submit')) + + // failed 是终态,首次轮询即结束,无需推进定时器 + await waitFor(() => expect(screen.getByTestId('task-error')).toBeInTheDocument()) + expect(screen.getByText(/Project not found: nope/)).toBeInTheDocument() + expect(api.fetchTaskResult).not.toHaveBeenCalled() + }) + + it('surfaces active-task reuse', async () => { + const user = userEvent.setup() + vi.mocked(api.startAsyncImport).mockResolvedValue({ + task_id: 't3', + status: 'running', + reused: true, + task_kind: 'cas_import_episode_package', + relation_type: 'cas_episode_import', + relation_entity_id: 'z'.repeat(64), + }) + vi.mocked(api.fetchTaskStatus).mockResolvedValue({ id: 't3', status: 'succeeded' }) + vi.mocked(api.fetchTaskResult).mockResolvedValue(null) + + renderPage() + await waitFor(() => expect(screen.getByTestId('import-panel')).toBeInTheDocument()) + await fillImportForm(user, '{"a":1}') + await user.click(screen.getByTestId('import-submit')) + + // succeeded 是终态,首次轮询即结束 + await waitFor(() => expect(screen.getByTestId('task-reused')).toBeInTheDocument()) + }) + + it('stops polling when the component unmounts', async () => { + vi.useFakeTimers() + vi.mocked(api.startAsyncImport).mockResolvedValue({ + task_id: 't4', + status: 'pending', + reused: false, + task_kind: 'cas_import_episode_package', + relation_type: 'cas_episode_import', + relation_entity_id: 'w'.repeat(64), + }) + vi.mocked(api.fetchTaskStatus).mockResolvedValue({ id: 't4', status: 'running' }) + + const { unmount } = renderPage() + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('import-panel')).toBeInTheDocument() + + fillAndSubmitSync('{"a":1}') + await vi.advanceTimersByTimeAsync(0) + expect(api.fetchTaskStatus).toHaveBeenCalledTimes(1) + + // 此时组件已排入下一次轮询定时器;卸载必须清掉它。 + // 用「推进后调用次数不变」来证明轮询确实停止:这是行为层面的保证, + // 且不会被 antd 内部可能存在的其它定时器干扰。 + unmount() + const callsAtUnmount = vi.mocked(api.fetchTaskStatus).mock.calls.length + await vi.advanceTimersByTimeAsync(10 * POLL_INTERVAL_MS) + expect(vi.mocked(api.fetchTaskStatus).mock.calls.length).toBe(callsAtUnmount) + }) +}) diff --git a/front/src/pages/aiStudio/cas/Ep001Workspace.tsx b/front/src/pages/aiStudio/cas/Ep001Workspace.tsx new file mode 100644 index 00000000..d911a0a2 --- /dev/null +++ b/front/src/pages/aiStudio/cas/Ep001Workspace.tsx @@ -0,0 +1,495 @@ +/** + * EP001 生产工作台(只读检视 + 本地异步导入入口)。 + * + * 纪律: + * - 复用既有 Project/Chapter/Shot/File/任务中心接口,不新增并行模型; + * - 字幕为只读产物:可预览、可下载,不提供原生编辑; + * - 轮询仅在任务非终态时进行,卸载与终态都会停止。 + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { + Alert, + Button, + Card, + Collapse, + Descriptions, + Empty, + Input, + Space, + Spin, + Table, + Tag, + Typography, +} from 'antd' + +import { + fetchChapter, + fetchShotBundles, + fetchSubtitleFiles, + fetchSubtitleText, + fetchTaskResult, + fetchTaskStatus, + isTerminalStatus, + startAsyncImport, + subtitleDownloadUrl, + WEBVTT_MIME_TYPE, + type ChapterRead, + type FileRead, + type ImportResult, + type ShotBundle, + type SubtitleArtifact, + type TaskStatusView, +} from '../../../services/casWorkspaceApi' +import { parseWebVtt, type ParsedVtt } from './webvtt' + +const POLL_INTERVAL_MS = 2000 +const MAX_POLLS = 90 // 有界轮询:约 3 分钟后停止,避免无限请求 + +function asText(value: unknown): string { + if (value === null || value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return '' +} + +export default function Ep001Workspace() { + const { projectId = '', chapterId = '' } = useParams() + + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [chapter, setChapter] = useState(null) + const [shots, setShots] = useState([]) + + const [subtitleFiles, setSubtitleFiles] = useState([]) + const [subtitleLoading, setSubtitleLoading] = useState(false) + const [subtitleError, setSubtitleError] = useState(null) + const [preview, setPreview] = useState(null) + const [artifactFromImport, setArtifactFromImport] = useState(null) + + const [task, setTask] = useState(null) + const [taskReused, setTaskReused] = useState(false) + const [importError, setImportError] = useState(null) + const [jsonText, setJsonText] = useState('') + const [idemKey, setIdemKey] = useState('') + const [submitting, setSubmitting] = useState(false) + const [importResult, setImportResult] = useState(null) + + const timerRef = useRef | null>(null) + const mountedRef = useRef(true) + + const stopPolling = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + const loadWorkspace = useCallback(async () => { + if (!chapterId) return + setLoading(true) + setLoadError(null) + try { + const [chapterData, shotData] = await Promise.all([ + fetchChapter(chapterId), + fetchShotBundles(chapterId), + ]) + if (!mountedRef.current) return + setChapter(chapterData) + setShots(shotData) + } catch (err) { + if (mountedRef.current) setLoadError((err as Error)?.message || '加载失败') + } finally { + if (mountedRef.current) setLoading(false) + } + }, [chapterId]) + + const loadSubtitle = useCallback(async () => { + if (!projectId || !chapterId) return + setSubtitleLoading(true) + setSubtitleError(null) + try { + const files = await fetchSubtitleFiles(projectId, chapterId) + if (!mountedRef.current) return + setSubtitleFiles(files) + if (files.length > 0) { + const text = await fetchSubtitleText(files[0].id) + if (!mountedRef.current) return + setPreview(parseWebVtt(text)) + } else { + setPreview(null) + } + } catch (err) { + if (mountedRef.current) setSubtitleError((err as Error)?.message || '字幕读取失败') + } finally { + if (mountedRef.current) setSubtitleLoading(false) + } + }, [projectId, chapterId]) + + useEffect(() => { + mountedRef.current = true + void loadWorkspace() + void loadSubtitle() + return () => { + mountedRef.current = false + stopPolling() + } + }, [loadWorkspace, loadSubtitle, stopPolling]) + + // 有界轮询:仅在非终态时继续,卸载/终态即停止。 + const pollTask = useCallback( + async (taskId: string, attempt: number) => { + if (!mountedRef.current || attempt > MAX_POLLS) return + try { + const status = await fetchTaskStatus(taskId) + if (!mountedRef.current) return + setTask(status) + if (isTerminalStatus(status.status)) { + stopPolling() + if (status.status === 'succeeded') { + const result = await fetchTaskResult(taskId) + if (!mountedRef.current) return + setImportResult(result) + const artifact = result?.subtitle_artifacts?.[0] ?? null + setArtifactFromImport(artifact) + await loadWorkspace() + await loadSubtitle() + } + return + } + timerRef.current = setTimeout(() => void pollTask(taskId, attempt + 1), POLL_INTERVAL_MS) + } catch (err) { + if (!mountedRef.current) return + setImportError((err as Error)?.message || '任务状态查询失败') + stopPolling() + } + }, + [loadSubtitle, loadWorkspace, stopPolling], + ) + + const handleImport = useCallback(async () => { + setImportError(null) + setImportResult(null) + setTaskReused(false) + + let parsed: unknown + try { + parsed = JSON.parse(jsonText) + } catch { + setImportError('Episode Package JSON 格式不正确(客户端校验;后端校验为准)') + return + } + if (!idemKey.trim()) { + setImportError('请填写 idempotency_key') + return + } + + setSubmitting(true) + try { + const accepted = await startAsyncImport({ + project_id: projectId, + idempotency_key: idemKey.trim(), + episode_package: parsed, + }) + if (!mountedRef.current) return + setTaskReused(!!accepted.reused) + setTask({ id: accepted.task_id, status: accepted.status as TaskStatusView['status'] }) + stopPolling() + void pollTask(accepted.task_id, 1) + } catch (err) { + if (mountedRef.current) { + const body = (err as { body?: { message?: string; detail?: string } })?.body + setImportError(body?.message || body?.detail || (err as Error)?.message || '导入失败') + } + } finally { + if (mountedRef.current) setSubmitting(false) + } + }, [idemKey, jsonText, pollTask, projectId, stopPolling]) + + const subtitleFile = subtitleFiles[0] + const totalDurationSeconds = useMemo(() => { + return shots.reduce((sum, bundle) => { + const duration = Number(bundle.detail?.duration ?? 0) + return sum + (Number.isFinite(duration) ? duration : 0) + }, 0) + }, [shots]) + + if (loading) { + return ( +
+ +
+ +
+ ) + } + + if (loadError) { + return ( +
+ void loadWorkspace()}> + 重试 + + } + /> +
+ ) + } + + return ( +
+ EP001 生产工作台 + + {/* --- 剧集摘要 --- */} + + + {chapter?.title || '—'} + {chapter?.summary || '—'} + {chapter?.project_id || projectId} + {chapter?.id || chapterId} + + {chapter?.storyboard_count ?? shots.length} + + + {totalDurationSeconds > 0 ? `${totalDurationSeconds.toFixed(1)} 秒` : '—'} + + {chapter?.status || '—'} + + {task?.status ? {task.status} : } + + + + + + {/* --- 镜头列表 --- */} + + {shots.length === 0 ? ( + + ) : ( + ({ + key: shot.id, + label: ( + + #{shot.index} {shot.title || '(untitled)'} + + ), + children: ( + + {asText(shot.status) || '—'} + + {asText(detail?.duration) || '—'} + + + {[ + asText(detail?.camera_shot), + asText(detail?.angle), + asText(detail?.movement), + ] + .filter(Boolean) + .join(' / ') || '—'} + + + {[asText(detail?.atmosphere), (detail?.action_beats ?? []).join(';')] + .filter(Boolean) + .join(' | ') || '—'} + + + {asText(shot.script_excerpt) || '—'} + + + {asText(detail?.key_frame_prompt) || '—'} + + + {dialogLines.length === 0 ? ( + '—' + ) : ( +
    + {dialogLines.map((line) => ( +
  • + {asText(line.speaker_name) || '—'}: + {asText(line.text)} +
  • + ))} +
+ )} +
+
+ ), + }))} + /> + )} +
+ + {/* --- 字幕产物 --- */} + + {subtitleLoading ? ( + +
+ + ) : subtitleError ? ( + void loadSubtitle()}> + 重试 + + } + /> + ) : !subtitleFile ? ( + + ) : ( + <> + + + {artifactFromImport?.language_tag || preview?.language || 'zh-Hant'} + + {subtitleFile.type} + {WEBVTT_MIME_TYPE} + + {artifactFromImport?.cue_count ?? preview?.cues.length ?? '—'} + + + {artifactFromImport?.byte_size ?? '—'} + + + {artifactFromImport + ? artifactFromImport.created + ? 'created' + : 'reused' + : '—'} + + {subtitleFile.id} + {subtitleFile.name} + + {/* FileRead 不暴露 storage_key,仅导入结果里有 */} + {artifactFromImport?.storage_key || '—'} + + + + + + + + {preview && !preview.valid && ( + + )} + {preview?.valid && ( + row.id || `${row.start}-${row.end}`} + dataSource={preview.cues} + pagination={false} + data-testid="subtitle-preview" + columns={[ + { title: 'Cue', dataIndex: 'id', width: 90 }, + { title: '入点', dataIndex: 'start', width: 130 }, + { title: '出点', dataIndex: 'end', width: 130 }, + { title: '镜头', dataIndex: 'shotId', width: 100 }, + // 纯文本渲染:React 会转义,字幕内容不会作为 HTML 执行。 + { title: '译文', dataIndex: 'text' }, + ]} + /> + )} + + )} + + + {/* --- 异步导入 --- */} + + {importError && ( + + )} + {taskReused && ( + + )} + {task && ( +
+ 任务 {task.id} 状态:{task.status} + {task.error ? 错误:{task.error} : null} +
+ )} + {importResult && ( + + )} + + + setIdemKey(e.target.value)} + aria-label="idempotency_key" + /> + setJsonText(e.target.value)} + aria-label="Episode Package JSON" + /> + + +
+ + ) +} diff --git a/front/src/pages/aiStudio/cas/webvtt.test.ts b/front/src/pages/aiStudio/cas/webvtt.test.ts new file mode 100644 index 00000000..b4d8de89 --- /dev/null +++ b/front/src/pages/aiStudio/cas/webvtt.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { parseWebVtt } from './webvtt' + +const EP001_VTT = `WEBVTT +Language: zh-Hant + +NOTE cue=1 shot=SC01 speaker=bruno_bull +c1 +00:00:00.400 --> 00:00:02.000 +突破了!我們回來了! + +NOTE cue=2 shot=SC02 speaker=boris_bear +c2 +00:00:03.400 --> 00:00:05.400 +這根K棒還沒收。 + +NOTE cue=3 shot=SC03 speaker=bruno_bull +c3 +00:00:11.000 --> 00:00:12.800 +還是綠的……對吧? + +NOTE cue=4 shot=SC04 speaker=milo_cat +c4 +00:00:17.000 --> 00:00:19.200 +你的彩帶會比收盤先到。 +` + +describe('parseWebVtt', () => { + it('parses the language tag and every cue in order', () => { + const parsed = parseWebVtt(EP001_VTT) + expect(parsed.valid).toBe(true) + expect(parsed.language).toBe('zh-Hant') + expect(parsed.cues).toHaveLength(4) + expect(parsed.cues.map((c) => c.id)).toEqual(['c1', 'c2', 'c3', 'c4']) + }) + + it('preserves exact timestamps and Traditional Chinese text', () => { + const parsed = parseWebVtt(EP001_VTT) + expect(parsed.cues[0]).toMatchObject({ + id: 'c1', + start: '00:00:00.400', + end: '00:00:02.000', + text: '突破了!我們回來了!', + shotId: 'SC01', + speaker: 'bruno_bull', + }) + expect(parsed.cues[3]).toMatchObject({ + start: '00:00:17.000', + end: '00:00:19.200', + text: '你的彩帶會比收盤先到。', + shotId: 'SC04', + }) + }) + + it('rejects content that is not WebVTT', () => { + expect(parseWebVtt('').valid).toBe(false) + expect(parseWebVtt('').valid).toBe(false) + }) + + it('keeps HTML-looking cue text as plain text (never markup)', () => { + const hostile = `WEBVTT + +c1 +00:00:00.000 --> 00:00:01.000 + +` + const parsed = parseWebVtt(hostile) + expect(parsed.valid).toBe(true) + // 解析结果只是字符串;渲染由 React 转义,不会成为 DOM 节点。 + expect(parsed.cues[0].text).toBe('') + expect(typeof parsed.cues[0].text).toBe('string') + }) + + it('tolerates CRLF and a BOM', () => { + const parsed = parseWebVtt('WEBVTT\r\nLanguage: zh-Hant\r\n\r\nc1\r\n00:00:00.000 --> 00:00:01.000\r\n測試\r\n') + expect(parsed.valid).toBe(true) + expect(parsed.cues[0].text).toBe('測試') + }) +}) diff --git a/front/src/pages/aiStudio/cas/webvtt.ts b/front/src/pages/aiStudio/cas/webvtt.ts new file mode 100644 index 00000000..201b14df --- /dev/null +++ b/front/src/pages/aiStudio/cas/webvtt.ts @@ -0,0 +1,81 @@ +/** + * 只读 WebVTT 解析(纯函数,无 DOM、无 HTML 求值)。 + * + * 安全性:解析结果只产出纯字符串,调用方用 React 文本节点渲染(React 默认转义), + * 绝不使用 dangerouslySetInnerHTML,因此字幕内容不会被当作 HTML 执行。 + */ + +export interface ParsedCue { + /** cue 标识符(WebVTT 的 identifier 行)。 */ + id: string + /** 起点时间戳原文,如 00:00:00.400。 */ + start: string + /** 终点时间戳原文。 */ + end: string + /** 字幕正文(多行以 \n 连接)。 */ + text: string + /** NOTE 中携带的镜头引用(若有)。 */ + shotId?: string + /** NOTE 中携带的说话人(若有)。 */ + speaker?: string +} + +export interface ParsedVtt { + language?: string + cues: ParsedCue[] + /** 无法识别为 WebVTT 时为 false。 */ + valid: boolean +} + +const TIMING_RE = /^(\S+)\s+-->\s+(\S+)/ + +/** + * 解析 WebVTT 文本。 + * + * 容错策略:结构异常时返回 valid=false 而不是抛错,让 UI 能显示明确的错误态。 + */ +export function parseWebVtt(input: string): ParsedVtt { + // 用转义写 BOM(U+FEFF):字面量 BOM 属于 irregular whitespace,且不易肉眼发现。 + const text = (input ?? '').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n') + if (!text.trimStart().startsWith('WEBVTT')) { + return { cues: [], valid: false } + } + + const blocks = text.split(/\n{2,}/) + const header = blocks[0] ?? '' + const languageMatch = header.match(/^Language:\s*(.+)$/m) + + const cues: ParsedCue[] = [] + for (const block of blocks.slice(1)) { + const lines = block.split('\n').filter((line) => line.trim() !== '') + if (lines.length === 0) continue + + let shotId: string | undefined + let speaker: string | undefined + let index = 0 + + // NOTE 行(可能有多行)先消费掉。 + while (index < lines.length && lines[index].startsWith('NOTE')) { + const note = lines[index] + shotId = note.match(/shot=([^\s]+)/)?.[1] ?? shotId + speaker = note.match(/speaker=([^\s]+)/)?.[1] ?? speaker + index += 1 + } + + // 可选的 identifier 行:下一行若不是时间轴,则当作 identifier。 + let id = '' + if (index < lines.length && !TIMING_RE.test(lines[index])) { + id = lines[index] + index += 1 + } + + const timing = index < lines.length ? lines[index].match(TIMING_RE) : null + if (!timing) continue + index += 1 + + const body = lines.slice(index).join('\n') + cues.push({ id, start: timing[1], end: timing[2], text: body, shotId, speaker }) + } + + return { language: languageMatch?.[1]?.trim(), cues, valid: true } +} diff --git a/front/src/services/casWorkspaceApi.ts b/front/src/services/casWorkspaceApi.ts new file mode 100644 index 00000000..039bb2fb --- /dev/null +++ b/front/src/services/casWorkspaceApi.ts @@ -0,0 +1,188 @@ +/** + * CAS EP001 工作台的数据访问层。 + * + * 纪律: + * - **全部**请求走生成客户端(src/services/generated),不保留任何手写的并行实现; + * - 复用既有任务中心状态接口(FilmService),不新建任务系统; + * - 字幕按既有 FileItem + FileUsage 关联检索(usage_kind=subtitle),不新增字幕表。 + */ +import { + CryptoAnimalStudioService, + FilmService, + StudioChaptersService, + StudioFilesService, + StudioShotDetailsService, + StudioShotDialogLinesService, + StudioShotsService, +} from './generated' +import type { + CasImportTaskAccepted, + ChapterRead, + FileRead, + ImportResult, + ShotDetailRead, + ShotDialogLineRead, + ShotRead, + SubtitleArtifact, +} from './generated' +import { buildFileDownloadUrl } from '../pages/aiStudio/assets/utils' + +/** 任务终态集合:到达即停止轮询。 */ +export const TERMINAL_TASK_STATUSES = ['succeeded', 'failed', 'cancelled'] as const + +export type TaskStatusValue = + | 'pending' + | 'running' + | 'streaming' + | 'succeeded' + | 'failed' + | 'cancelled' + +export interface TaskStatusView { + id: string + status: TaskStatusValue + progress?: number + error?: string +} + +/** 单个镜头在工作台中的聚合视图(Shot + ShotDetail + 对白)。 */ +export interface ShotBundle { + shot: ShotRead + detail: ShotDetailRead | null + dialogLines: ShotDialogLineRead[] +} + +export function isTerminalStatus(status: string | undefined): boolean { + return !!status && (TERMINAL_TASK_STATUSES as readonly string[]).includes(status) +} + +/** WebVTT 的 MIME 类型(产物生成时写入的 content-type)。 */ +export const WEBVTT_MIME_TYPE = 'text/vtt' + +/** 取章节详情(Chapter 即剧集实体)。 */ +export async function fetchChapter(chapterId: string): Promise { + const res = await StudioChaptersService.getChapterApiV1StudioChaptersChapterIdGet({ chapterId }) + return res.data ?? null +} + +/** + * 取章节下的镜头,并聚合各自的 ShotDetail 与对白。 + * + * ShotRead 不内嵌 detail/对白,因此分别调用既有子资源接口再按 index 排序。 + */ +export async function fetchShotBundles(chapterId: string): Promise { + const listed = await StudioShotsService.listShotsApiV1StudioShotsGet({ + chapterId, + pageSize: 100, + order: 'index', + }) + const shots = (listed.data?.items ?? []).slice().sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + + return Promise.all( + shots.map(async (shot) => { + let detail: ShotDetailRead | null = null + let dialogLines: ShotDialogLineRead[] = [] + try { + const detailRes = + await StudioShotDetailsService.getShotDetailApiV1StudioShotDetailsShotIdGet({ + shotId: shot.id, + }) + detail = detailRes.data ?? null + } catch { + detail = null // 细节缺失不应让整页失败 + } + if (detail) { + try { + const lines = + await StudioShotDialogLinesService.listShotDialogLinesApiV1StudioShotDialogLinesGet({ + shotDetailId: detail.id, + pageSize: 100, + order: 'index', + }) + dialogLines = (lines.data?.items ?? []) + .slice() + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + } catch { + dialogLines = [] + } + } + return { shot, detail, dialogLines } + }), + ) +} + +/** + * 按章节取字幕产物(usage_kind=subtitle)。 + * + * 用 chapter_id 而不是 chapter_title:标题不唯一,ID 才能稳定定位。 + */ +export async function fetchSubtitleFiles( + projectId: string, + chapterId: string, +): Promise { + const res = await StudioFilesService.listFilesApiApiV1StudioFilesGet({ + projectId, + chapterId, + usageKind: 'subtitle', + pageSize: 50, + }) + return res.data?.items ?? [] +} + +/** + * 下载字幕原文(文本),用于只读预览。 + * + * 生成客户端对非 JSON 响应返回 `response.text()`,因此这里直接得到 WebVTT 字符串。 + */ +export async function fetchSubtitleText(fileId: string): Promise { + const res = await StudioFilesService.downloadFileApiApiV1StudioFilesFileIdDownloadGet({ fileId }) + return typeof res === 'string' ? res : String(res ?? '') +} + +/** 既有下载端点的绝对地址(复用仓库既有的 buildFileDownloadUrl)。 */ +export function subtitleDownloadUrl(fileId: string): string { + return buildFileDownloadUrl(fileId) ?? '' +} + +/** 发起异步导入(既有 CAS 端点,经生成客户端调用)。 */ +export async function startAsyncImport(payload: { + project_id: string + idempotency_key: string + episode_package: unknown + dry_run?: boolean +}): Promise { + const res = + await CryptoAnimalStudioService.importEpisodeAsyncEndpointApiV1CryptoAnimalStudioImportAsyncPost( + { requestBody: payload as never }, + ) + const data = res.data + if (!data) { + throw new Error('import/async returned no data') + } + return data +} + +/** 查询任务状态(既有任务中心)。 */ +export async function fetchTaskStatus(taskId: string): Promise { + const res = await FilmService.getTaskStatusApiV1FilmTasksTaskIdStatusGet({ taskId }) + const data = (res as { data?: unknown })?.data + return (data ?? res) as TaskStatusView +} + +/** 查询任务结果(成功后拿 ImportResult,含 subtitle_artifacts)。 */ +export async function fetchTaskResult(taskId: string): Promise { + const res = await FilmService.getTaskResultApiV1FilmTasksTaskIdResultGet({ taskId }) + const data = (res as { data?: unknown })?.data + return (data as ImportResult | null) ?? null +} + +export type { + CasImportTaskAccepted, + ChapterRead, + FileRead, + ImportResult, + ShotDetailRead, + ShotDialogLineRead, + ShotRead, + SubtitleArtifact, +} diff --git a/front/src/services/generated/index.ts b/front/src/services/generated/index.ts index b1afde7e..b89812a1 100644 --- a/front/src/services/generated/index.ts +++ b/front/src/services/generated/index.ts @@ -8,7 +8,9 @@ export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; export type { ActionBeatPhaseRead } from './models/ActionBeatPhaseRead'; +export type { ActorAsset } from './models/ActorAsset'; export type { ApiResponse_AsyncTaskCreateRead_ } from './models/ApiResponse_AsyncTaskCreateRead_'; +export type { ApiResponse_CasImportTaskAccepted_ } from './models/ApiResponse_CasImportTaskAccepted_'; export type { ApiResponse_ChapterRead_ } from './models/ApiResponse_ChapterRead_'; export type { ApiResponse_CharacterPortraitAnalysisResult_ } from './models/ApiResponse_CharacterPortraitAnalysisResult_'; export type { ApiResponse_CostumeInfoAnalysisResult_ } from './models/ApiResponse_CostumeInfoAnalysisResult_'; @@ -20,6 +22,8 @@ export type { ApiResponse_FileDetailRead_ } from './models/ApiResponse_FileDetai export type { ApiResponse_FileRead_ } from './models/ApiResponse_FileRead_'; export type { ApiResponse_GenerationTaskLinkRead_ } from './models/ApiResponse_GenerationTaskLinkRead_'; export type { ApiResponse_ImageGenerationOptionsRead_ } from './models/ApiResponse_ImageGenerationOptionsRead_'; +export type { ApiResponse_ImportResult_ } from './models/ApiResponse_ImportResult_'; +export type { ApiResponse_list_ProductionArtifactView__ } from './models/ApiResponse_list_ProductionArtifactView__'; export type { ApiResponse_list_PromptCategoryOptionRead__ } from './models/ApiResponse_list_PromptCategoryOptionRead__'; export type { ApiResponse_list_ProviderSupportedRead__ } from './models/ApiResponse_list_ProviderSupportedRead__'; export type { ApiResponse_list_ShotCharacterLinkRead__ } from './models/ApiResponse_list_ShotCharacterLinkRead__'; @@ -44,6 +48,7 @@ export type { ApiResponse_PaginatedData_ShotFrameImageRead__ } from './models/Ap export type { ApiResponse_PaginatedData_ShotLinkedAssetItem__ } from './models/ApiResponse_PaginatedData_ShotLinkedAssetItem__'; export type { ApiResponse_PaginatedData_ShotRead__ } from './models/ApiResponse_PaginatedData_ShotRead__'; export type { ApiResponse_PaginatedData_TaskListItemRead__ } from './models/ApiResponse_PaginatedData_TaskListItemRead__'; +export type { ApiResponse_ProductionJobView_ } from './models/ApiResponse_ProductionJobView_'; export type { ApiResponse_ProjectActorLinkRead_ } from './models/ApiResponse_ProjectActorLinkRead_'; export type { ApiResponse_ProjectCostumeLinkRead_ } from './models/ApiResponse_ProjectCostumeLinkRead_'; export type { ApiResponse_ProjectPropLinkRead_ } from './models/ApiResponse_ProjectPropLinkRead_'; @@ -79,21 +84,33 @@ export type { ApiResponse_TaskStatusRead_ } from './models/ApiResponse_TaskStatu export type { ApiResponse_VariantAnalysisResult_ } from './models/ApiResponse_VariantAnalysisResult_'; export type { ApiResponse_VideoGenerationOptionsRead_ } from './models/ApiResponse_VideoGenerationOptionsRead_'; export type { ApiResponse_VideoPromptPreviewResponse_ } from './models/ApiResponse_VideoPromptPreviewResponse_'; +export type { AssetLibrary } from './models/AssetLibrary'; export type { AsyncTaskCreateRead } from './models/AsyncTaskCreateRead'; export type { Body_upload_file_api_api_v1_studio_files_upload_post } from './models/Body_upload_file_api_api_v1_studio_files_upload_post'; export type { CameraAngle } from './models/CameraAngle'; export type { CameraMovement } from './models/CameraMovement'; export type { CameraShotType } from './models/CameraShotType'; +export type { CameraSpec } from './models/CameraSpec'; +export type { CasCameraAngle } from './models/CasCameraAngle'; +export type { CasCameraMovement } from './models/CasCameraMovement'; +export type { CasImportTaskAccepted } from './models/CasImportTaskAccepted'; +export type { CasShotType } from './models/CasShotType'; export type { ChapterCreate } from './models/ChapterCreate'; export type { ChapterRead } from './models/ChapterRead'; export type { ChapterStatus } from './models/ChapterStatus'; export type { ChapterUpdate } from './models/ChapterUpdate'; export type { CharacterPortraitAnalysisRequest } from './models/CharacterPortraitAnalysisRequest'; export type { CharacterPortraitAnalysisResult } from './models/CharacterPortraitAnalysisResult'; +export type { CharacterSpec } from './models/CharacterSpec'; +export type { CostumeAsset } from './models/CostumeAsset'; export type { CostumeInfoAnalysisRequest } from './models/CostumeInfoAnalysisRequest'; export type { CostumeInfoAnalysisResult } from './models/CostumeInfoAnalysisResult'; export type { CostumeTimeline } from './models/CostumeTimeline'; export type { CostumeTimelineEntry } from './models/CostumeTimelineEntry'; +export type { CreateProductionJobRequest } from './models/CreateProductionJobRequest'; +export type { CreativeDirection } from './models/CreativeDirection'; +export type { DataLock } from './models/DataLock'; +export type { DialogueLine } from './models/DialogueLine'; export type { DialogueLineMode } from './models/DialogueLineMode'; export type { EntityEntry } from './models/EntityEntry'; export type { EntityLibrary } from './models/EntityLibrary'; @@ -103,7 +120,12 @@ export type { EntityNameExistenceCheckRequest } from './models/EntityNameExisten export type { EntityNameExistenceCheckResponse } from './models/EntityNameExistenceCheckResponse'; export type { EntityNameExistenceItem } from './models/EntityNameExistenceItem'; export type { EntityVariant } from './models/EntityVariant'; +export type { EpisodeMetadata } from './models/EpisodeMetadata'; +export type { EpisodePackage } from './models/EpisodePackage'; +export type { EpisodePackageV11 } from './models/EpisodePackageV11'; export type { EvidenceSpan } from './models/EvidenceSpan'; +export type { FactCard } from './models/FactCard'; +export type { FactCardLocalizedCopy } from './models/FactCardLocalizedCopy'; export type { FileDetailRead } from './models/FileDetailRead'; export type { FileRead } from './models/FileRead'; export type { FileTypeEnum } from './models/FileTypeEnum'; @@ -116,13 +138,21 @@ export type { GenerationTaskLinkRead } from './models/GenerationTaskLinkRead'; export type { GenerationTaskLinkUpdate } from './models/GenerationTaskLinkUpdate'; export type { HTTPValidationError } from './models/HTTPValidationError'; export type { ImageGenerationOptionsRead } from './models/ImageGenerationOptionsRead'; +export type { ImportCounts } from './models/ImportCounts'; +export type { ImportEpisodeRequest } from './models/ImportEpisodeRequest'; +export type { ImportResult } from './models/ImportResult'; +export type { Localization } from './models/Localization'; export type { LogLevel } from './models/LogLevel'; +export type { MarketData } from './models/MarketData'; export type { ModelCategoryKey } from './models/ModelCategoryKey'; export type { ModelCreate } from './models/ModelCreate'; export type { ModelRead } from './models/ModelRead'; export type { ModelSettingsRead } from './models/ModelSettingsRead'; export type { ModelSettingsUpdate } from './models/ModelSettingsUpdate'; export type { ModelUpdate } from './models/ModelUpdate'; +export type { NewsSource } from './models/NewsSource'; +export type { OutputSpec } from './models/OutputSpec'; +export type { OverlayLocalizedText } from './models/OverlayLocalizedText'; export type { PaginatedData_Any_ } from './models/PaginatedData_Any_'; export type { PaginatedData_ChapterRead_ } from './models/PaginatedData_ChapterRead_'; export type { PaginatedData_dict_str__Any__ } from './models/PaginatedData_dict_str__Any__'; @@ -139,6 +169,11 @@ export type { PaginatedData_ShotLinkedAssetItem_ } from './models/PaginatedData_ export type { PaginatedData_ShotRead_ } from './models/PaginatedData_ShotRead_'; export type { PaginatedData_TaskListItemRead_ } from './models/PaginatedData_TaskListItemRead_'; export type { Pagination } from './models/Pagination'; +export type { PostProduction } from './models/PostProduction'; +export type { PostProductionOverlay } from './models/PostProductionOverlay'; +export type { ProductionArtifactView } from './models/ProductionArtifactView'; +export type { ProductionJobView } from './models/ProductionJobView'; +export type { ProductionShotView } from './models/ProductionShotView'; export type { ProjectActorLinkRead } from './models/ProjectActorLinkRead'; export type { ProjectAssetLinkCreate } from './models/ProjectAssetLinkCreate'; export type { ProjectCostumeLinkRead } from './models/ProjectCostumeLinkRead'; @@ -155,6 +190,7 @@ export type { PromptCategoryOptionRead } from './models/PromptCategoryOptionRead export type { PromptTemplateCreate } from './models/PromptTemplateCreate'; export type { PromptTemplateRead } from './models/PromptTemplateRead'; export type { PromptTemplateUpdate } from './models/PromptTemplateUpdate'; +export type { PropAsset } from './models/PropAsset'; export type { PropInfoAnalysisRequest } from './models/PropInfoAnalysisRequest'; export type { PropInfoAnalysisResult } from './models/PropInfoAnalysisResult'; export type { ProviderCreate } from './models/ProviderCreate'; @@ -162,8 +198,14 @@ export type { ProviderRead } from './models/ProviderRead'; export type { ProviderStatus } from './models/ProviderStatus'; export type { ProviderSupportedRead } from './models/ProviderSupportedRead'; export type { ProviderUpdate } from './models/ProviderUpdate'; +export type { ReferenceAsset } from './models/ReferenceAsset'; +export type { References } from './models/References'; +export type { RegenerationFallback } from './models/RegenerationFallback'; export type { RenderedPromptResponse } from './models/RenderedPromptResponse'; export type { RenderedShotFramePromptRead } from './models/RenderedShotFramePromptRead'; +export type { RetryProductionJobRequest } from './models/RetryProductionJobRequest'; +export type { SafeArea } from './models/SafeArea'; +export type { SceneAsset } from './models/SceneAsset'; export type { SceneInfoAnalysisRequest } from './models/SceneInfoAnalysisRequest'; export type { SceneInfoAnalysisResult } from './models/SceneInfoAnalysisResult'; export type { ScriptConsistencyCheckRequest } from './models/ScriptConsistencyCheckRequest'; @@ -176,6 +218,7 @@ export type { ScriptOptimizationResult } from './models/ScriptOptimizationResult export type { ScriptOptimizeRequest } from './models/ScriptOptimizeRequest'; export type { ScriptSimplificationResult } from './models/ScriptSimplificationResult'; export type { ScriptSimplifyRequest } from './models/ScriptSimplifyRequest'; +export type { Shot } from './models/Shot'; export type { ShotAssetOverviewItem } from './models/ShotAssetOverviewItem'; export type { ShotAssetsOverviewRead } from './models/ShotAssetsOverviewRead'; export type { ShotAssetsOverviewSummary } from './models/ShotAssetsOverviewSummary'; @@ -219,6 +262,7 @@ export type { ShotSemanticSuggestion } from './models/ShotSemanticSuggestion'; export type { ShotSkipExtractionUpdate } from './models/ShotSkipExtractionUpdate'; export type { ShotStatus } from './models/ShotStatus'; export type { ShotUpdate } from './models/ShotUpdate'; +export type { ShotV11 } from './models/ShotV11'; export type { ShotVideoPromptPackRead } from './models/ShotVideoPromptPackRead'; export type { ShotVideoPromptPreviewRead } from './models/ShotVideoPromptPreviewRead'; export type { ShotVideoReadinessCheck } from './models/ShotVideoReadinessCheck'; @@ -230,6 +274,9 @@ export type { StudioScriptExtractionDraft } from './models/StudioScriptExtractio export type { StudioShotDraft } from './models/StudioShotDraft'; export type { StudioShotDraftDialogueLine } from './models/StudioShotDraftDialogueLine'; export type { StyleOption } from './models/StyleOption'; +export type { SubtitleArtifact } from './models/SubtitleArtifact'; +export type { SubtitleCue } from './models/SubtitleCue'; +export type { SubtitleTrack } from './models/SubtitleTrack'; export type { TaskCancelRead } from './models/TaskCancelRead'; export type { TaskCancelRequest } from './models/TaskCancelRequest'; export type { TaskCreated } from './models/TaskCreated'; @@ -248,6 +295,8 @@ export type { VideoGenerationOptionsRead } from './models/VideoGenerationOptions export type { VideoGenerationTaskRequest } from './models/VideoGenerationTaskRequest'; export type { VideoPromptPreviewResponse } from './models/VideoPromptPreviewResponse'; +export { CryptoAnimalStudioService } from './services/CryptoAnimalStudioService'; +export { CryptoAnimalStudioProductionService } from './services/CryptoAnimalStudioProductionService'; export { DefaultService } from './services/DefaultService'; export { FilmService } from './services/FilmService'; export { HealthService } from './services/HealthService'; diff --git a/front/src/services/generated/models/ActorAsset.ts b/front/src/services/generated/models/ActorAsset.ts new file mode 100644 index 00000000..b9c566e1 --- /dev/null +++ b/front/src/services/generated/models/ActorAsset.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 视觉演员素材(跨角色/跨集可复用的视觉身份)。 + */ +export type ActorAsset = { + /** + * 演员键(素材类别内唯一) + */ + actor_key: string; + /** + * 展示名 + */ + display_name?: string; + /** + * 外观/视觉描述 + */ + description?: string; +}; + diff --git a/front/src/services/generated/models/ApiResponse_CasImportTaskAccepted_.ts b/front/src/services/generated/models/ApiResponse_CasImportTaskAccepted_.ts new file mode 100644 index 00000000..fc23c91e --- /dev/null +++ b/front/src/services/generated/models/ApiResponse_CasImportTaskAccepted_.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CasImportTaskAccepted } from './CasImportTaskAccepted'; +export type ApiResponse_CasImportTaskAccepted_ = { + /** + * 与 HTTP 状态码一致 + */ + code?: number; + /** + * 提示信息 + */ + message?: string; + /** + * 实际数据 + */ + data?: (CasImportTaskAccepted | null); + /** + * 附加元信息 + */ + meta?: (Record | null); +}; + diff --git a/front/src/services/generated/models/ApiResponse_ImportResult_.ts b/front/src/services/generated/models/ApiResponse_ImportResult_.ts new file mode 100644 index 00000000..ab7523d5 --- /dev/null +++ b/front/src/services/generated/models/ApiResponse_ImportResult_.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ImportResult } from './ImportResult'; +export type ApiResponse_ImportResult_ = { + /** + * 与 HTTP 状态码一致 + */ + code?: number; + /** + * 提示信息 + */ + message?: string; + /** + * 实际数据 + */ + data?: (ImportResult | null); + /** + * 附加元信息 + */ + meta?: (Record | null); +}; + diff --git a/front/src/services/generated/models/ApiResponse_ProductionJobView_.ts b/front/src/services/generated/models/ApiResponse_ProductionJobView_.ts new file mode 100644 index 00000000..14558e62 --- /dev/null +++ b/front/src/services/generated/models/ApiResponse_ProductionJobView_.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ProductionJobView } from './ProductionJobView'; +export type ApiResponse_ProductionJobView_ = { + /** + * 与 HTTP 状态码一致 + */ + code?: number; + /** + * 提示信息 + */ + message?: string; + /** + * 实际数据 + */ + data?: (ProductionJobView | null); + /** + * 附加元信息 + */ + meta?: (Record | null); +}; + diff --git a/front/src/services/generated/models/ApiResponse_list_ProductionArtifactView__.ts b/front/src/services/generated/models/ApiResponse_list_ProductionArtifactView__.ts new file mode 100644 index 00000000..b3f4a57f --- /dev/null +++ b/front/src/services/generated/models/ApiResponse_list_ProductionArtifactView__.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ProductionArtifactView } from './ProductionArtifactView'; +export type ApiResponse_list_ProductionArtifactView__ = { + /** + * 与 HTTP 状态码一致 + */ + code?: number; + /** + * 提示信息 + */ + message?: string; + /** + * 实际数据 + */ + data?: (Array | null); + /** + * 附加元信息 + */ + meta?: (Record | null); +}; + diff --git a/front/src/services/generated/models/AssetLibrary.ts b/front/src/services/generated/models/AssetLibrary.ts new file mode 100644 index 00000000..36b06e1b --- /dev/null +++ b/front/src/services/generated/models/AssetLibrary.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ActorAsset } from './ActorAsset'; +import type { CostumeAsset } from './CostumeAsset'; +import type { PropAsset } from './PropAsset'; +import type { SceneAsset } from './SceneAsset'; +/** + * 一集的素材库:演员 / 场景 / 道具 / 服装。 + */ +export type AssetLibrary = { + /** + * 演员素材列表 + */ + actors?: Array; + /** + * 场景素材列表 + */ + scenes?: Array; + /** + * 道具素材列表 + */ + props?: Array; + /** + * 服装素材列表 + */ + costumes?: Array; +}; + diff --git a/front/src/services/generated/models/CameraSpec.ts b/front/src/services/generated/models/CameraSpec.ts new file mode 100644 index 00000000..d22b61d6 --- /dev/null +++ b/front/src/services/generated/models/CameraSpec.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CasCameraAngle } from './CasCameraAngle'; +import type { CasCameraMovement } from './CasCameraMovement'; +import type { CasShotType } from './CasShotType'; +/** + * 镜头的结构化相机描述。 + * + * v1.1 起将「camera 自由文本」升级为结构化对象,字段与 Jellyfish ShotDetail 的 + * ``camera_shot`` / ``angle`` / ``movement`` 概念一一对应,便于导入器干净映射。 + * 三个字段均可选(storyboard 未指定时留空);取值由 CAS 本地枚举校验, + * **不**从 Jellyfish ORM/枚举导入。 + */ +export type CameraSpec = { + /** + * 景别(ECU/CU/MCU/MS/MLS/LS/ELS) + */ + shot_type?: (CasShotType | null); + /** + * 机位角度(EYE_LEVEL/HIGH_ANGLE/LOW_ANGLE/BIRD_EYE/DUTCH/OVER_SHOULDER) + */ + angle?: (CasCameraAngle | null); + /** + * 运镜(STATIC/PAN/TILT/DOLLY_IN/DOLLY_OUT/TRACK/CRANE/HANDHELD/STEADICAM/ZOOM_IN/ZOOM_OUT) + */ + movement?: (CasCameraMovement | null); +}; + diff --git a/front/src/services/generated/models/CasCameraAngle.ts b/front/src/services/generated/models/CasCameraAngle.ts new file mode 100644 index 00000000..b2c3f274 --- /dev/null +++ b/front/src/services/generated/models/CasCameraAngle.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 机位角度(对齐 Jellyfish CameraAngle 的 code)。 + */ +export type CasCameraAngle = 'EYE_LEVEL' | 'HIGH_ANGLE' | 'LOW_ANGLE' | 'BIRD_EYE' | 'DUTCH' | 'OVER_SHOULDER'; diff --git a/front/src/services/generated/models/CasCameraMovement.ts b/front/src/services/generated/models/CasCameraMovement.ts new file mode 100644 index 00000000..8fd138e0 --- /dev/null +++ b/front/src/services/generated/models/CasCameraMovement.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 运镜方式(对齐 Jellyfish CameraMovement 的 code)。 + */ +export type CasCameraMovement = 'STATIC' | 'PAN' | 'TILT' | 'DOLLY_IN' | 'DOLLY_OUT' | 'TRACK' | 'CRANE' | 'HANDHELD' | 'STEADICAM' | 'ZOOM_IN' | 'ZOOM_OUT'; diff --git a/front/src/services/generated/models/CasImportTaskAccepted.ts b/front/src/services/generated/models/CasImportTaskAccepted.ts new file mode 100644 index 00000000..be064556 --- /dev/null +++ b/front/src/services/generated/models/CasImportTaskAccepted.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * POST /import/async 的响应体:任务已受理。 + */ +export type CasImportTaskAccepted = { + /** + * 任务中心任务 ID + */ + task_id: string; + /** + * 任务状态(pending/running/...) + */ + status: string; + /** + * 是否复用了同一剧集的活动任务 + */ + reused: boolean; + /** + * 任务种类(cas_import_episode_package) + */ + task_kind: string; + /** + * 业务关联类型 + */ + relation_type: string; + /** + * 业务关联实体键(project+episode 摘要) + */ + relation_entity_id: string; +}; + diff --git a/front/src/services/generated/models/CasShotType.ts b/front/src/services/generated/models/CasShotType.ts new file mode 100644 index 00000000..66c045ed --- /dev/null +++ b/front/src/services/generated/models/CasShotType.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 景别(对齐 Jellyfish CameraShotType 的 code)。 + */ +export type CasShotType = 'ECU' | 'CU' | 'MCU' | 'MS' | 'MLS' | 'LS' | 'ELS'; diff --git a/front/src/services/generated/models/CharacterSpec.ts b/front/src/services/generated/models/CharacterSpec.ts new file mode 100644 index 00000000..5e095f7b --- /dev/null +++ b/front/src/services/generated/models/CharacterSpec.ts @@ -0,0 +1,45 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 出场角色定义(叙事角色)。 + * + * ``character_key`` 为本集内稳定引用键;``actor_key`` / ``costume_key`` 指向素材库 + * (视觉演员 / 服装),用于 Jellyfish 侧的一致性与选角映射。 + */ +export type CharacterSpec = { + /** + * 角色键(本集内唯一,非空) + */ + character_key: string; + /** + * 展示名(如 Bull) + */ + display_name: string; + /** + * 叙事角色定位(如 main、chaos_agent、straight_man) + */ + role?: string; + /** + * 角色描述 + */ + description?: string; + /** + * 对应 assets.actors 中的 actor_key(可选) + */ + actor_key?: (string | null); + /** + * 对应 assets.costumes 中的 costume_key(可选) + */ + costume_key?: (string | null); + /** + * 声音设定(可选) + */ + voice_profile?: (string | null); + /** + * 角色连续性备注(可选) + */ + continuity_notes?: string; +}; + diff --git a/front/src/services/generated/models/CostumeAsset.ts b/front/src/services/generated/models/CostumeAsset.ts new file mode 100644 index 00000000..323d8346 --- /dev/null +++ b/front/src/services/generated/models/CostumeAsset.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 服装素材。 + */ +export type CostumeAsset = { + /** + * 服装键(素材类别内唯一) + */ + costume_key: string; + /** + * 展示名 + */ + display_name?: string; + /** + * 服装描述 + */ + description?: string; +}; + diff --git a/front/src/services/generated/models/CreateProductionJobRequest.ts b/front/src/services/generated/models/CreateProductionJobRequest.ts new file mode 100644 index 00000000..7f54e1e7 --- /dev/null +++ b/front/src/services/generated/models/CreateProductionJobRequest.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EpisodePackage } from './EpisodePackage'; +import type { EpisodePackageV11 } from './EpisodePackageV11'; +/** + * POST /production/jobs 请求体。 + */ +export type CreateProductionJobRequest = { + /** + * 项目 ID + */ + project_id: string; + /** + * 待生产的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1) + */ + episode_package: (EpisodePackageV11 | EpisodePackage); + /** + * 供应商模式;本冲刺仅支持 mock + */ + mode?: string; +}; + diff --git a/front/src/services/generated/models/CreativeDirection.ts b/front/src/services/generated/models/CreativeDirection.ts new file mode 100644 index 00000000..f5522436 --- /dev/null +++ b/front/src/services/generated/models/CreativeDirection.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 一集的创意方向:格式、基调、时长目标与风格。 + */ +export type CreativeDirection = { + /** + * 内容格式(如 short_form_vertical) + */ + format?: string; + /** + * 整体基调(如 deadpan、satirical) + */ + tone?: string; + /** + * 目标时长(秒),必须大于零 + */ + target_duration_seconds: number; + /** + * 视觉风格(如 anime、cel-shaded) + */ + visual_style?: string; + /** + * 喜剧风格(如 false_confidence + callback) + */ + comedy_style?: string; + /** + * 连续性备注:跨集/跨镜需保持的设定 + */ + continuity_notes?: string; +}; + diff --git a/front/src/services/generated/models/DataLock.ts b/front/src/services/generated/models/DataLock.ts new file mode 100644 index 00000000..1a949d14 --- /dev/null +++ b/front/src/services/generated/models/DataLock.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 市场数据锁定状态。 + */ +export type DataLock = { + /** + * 锁定状态 + */ + status?: 'unresolved' | 'locked'; + /** + * 锁定时间(ISO-8601) + */ + locked_at_utc?: (string | null); +}; + diff --git a/front/src/services/generated/models/DialogueLine.ts b/front/src/services/generated/models/DialogueLine.ts new file mode 100644 index 00000000..872cfbda --- /dev/null +++ b/front/src/services/generated/models/DialogueLine.ts @@ -0,0 +1,29 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 镜头内单条对白。 + * + * ``order`` 为镜头内排序(正整数、镜头内唯一);``character_key`` 若提供, + * 必须能在 ``characters`` 中找到(根模型统一校验)。 + */ +export type DialogueLine = { + /** + * 镜头内排序(正整数,镜头内唯一) + */ + order: number; + /** + * 说话角色键(可选;旁白可为空) + */ + character_key?: (string | null); + /** + * 台词正文(非空) + */ + text: string; + /** + * 对白模式:DIALOGUE/VOICE_OVER/OFF_SCREEN/PHONE + */ + line_mode?: string; +}; + diff --git a/front/src/services/generated/models/EpisodeMetadata.ts b/front/src/services/generated/models/EpisodeMetadata.ts new file mode 100644 index 00000000..79d8e4f1 --- /dev/null +++ b/front/src/services/generated/models/EpisodeMetadata.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 一集的生成元信息(用于追溯)。 + */ +export type EpisodeMetadata = { + /** + * 生成时间(ISO-8601 字符串,可选) + */ + created_at?: (string | null); + /** + * 生成器标识(如 creative-os) + */ + generator?: string; + /** + * 所用模型标识 + */ + model?: string; + /** + * 提示词版本 + */ + prompt_version?: string; + /** + * 标签 + */ + tags?: Array; +}; + diff --git a/front/src/services/generated/models/EpisodePackage.ts b/front/src/services/generated/models/EpisodePackage.ts new file mode 100644 index 00000000..d73d5fdf --- /dev/null +++ b/front/src/services/generated/models/EpisodePackage.ts @@ -0,0 +1,64 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AssetLibrary } from './AssetLibrary'; +import type { CharacterSpec } from './CharacterSpec'; +import type { CreativeDirection } from './CreativeDirection'; +import type { EpisodeMetadata } from './EpisodeMetadata'; +import type { NewsSource } from './NewsSource'; +import type { Shot } from './Shot'; +/** + * EpisodePackage v1 根对象:一集的完整交付包。 + * + * 一个 EpisodePackage 对应 Jellyfish 的一个 Chapter;其 ``shots`` 直接建立 + * Jellyfish 的 Shot(不回送 ScriptDivider)。跨引用完整性由 ``_validate_cross_references`` + * 统一校验。 + */ +export type EpisodePackage = { + /** + * 契约版本;v1 必须等于 "1.0" + */ + schema_version: string; + /** + * 一集的唯一 ID(非空) + */ + episode_id: string; + /** + * 剧集标题(非空) + */ + title: string; + /** + * 一句话梗概 + */ + logline?: string; + /** + * 语言(如 en、zh;非空) + */ + language: string; + /** + * 素材来源 + */ + source: NewsSource; + /** + * 创意方向 + */ + creative_direction: CreativeDirection; + /** + * 出场角色(键须唯一) + */ + characters: Array; + /** + * 素材库 + */ + assets: AssetLibrary; + /** + * 镜头列表(至少一个) + */ + shots: Array; + /** + * 生成元信息 + */ + metadata: EpisodeMetadata; +}; + diff --git a/front/src/services/generated/models/EpisodePackageV11.ts b/front/src/services/generated/models/EpisodePackageV11.ts new file mode 100644 index 00000000..bcc53384 --- /dev/null +++ b/front/src/services/generated/models/EpisodePackageV11.ts @@ -0,0 +1,90 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AssetLibrary } from './AssetLibrary'; +import type { CharacterSpec } from './CharacterSpec'; +import type { CreativeDirection } from './CreativeDirection'; +import type { EpisodeMetadata } from './EpisodeMetadata'; +import type { FactCard } from './FactCard'; +import type { Localization } from './Localization'; +import type { MarketData } from './MarketData'; +import type { NewsSource } from './NewsSource'; +import type { OutputSpec } from './OutputSpec'; +import type { PostProduction } from './PostProduction'; +import type { References } from './References'; +import type { ShotV11 } from './ShotV11'; +/** + * EpisodePackage v1.1 根对象:v1 全部字段 + 六个可选顶层对象;shots 使用 ShotV11。 + */ +export type EpisodePackageV11 = { + /** + * 契约版本;v1 必须等于 "1.0" + */ + schema_version: string; + /** + * 一集的唯一 ID(非空) + */ + episode_id: string; + /** + * 剧集标题(非空) + */ + title: string; + /** + * 一句话梗概 + */ + logline?: string; + /** + * 语言(如 en、zh;非空) + */ + language: string; + /** + * 素材来源 + */ + source: NewsSource; + /** + * 创意方向 + */ + creative_direction: CreativeDirection; + /** + * 出场角色(键须唯一) + */ + characters: Array; + /** + * 素材库 + */ + assets: AssetLibrary; + /** + * 镜头列表(至少一个) + */ + shots: Array; + /** + * 生成元信息 + */ + metadata: EpisodeMetadata; + /** + * 输出规格(缺省时用文档化默认值) + */ + output?: (OutputSpec | null); + /** + * 口语与字幕 + */ + localization?: (Localization | null); + /** + * 后期 fact card + */ + fact_card?: (FactCard | null); + /** + * 市场事实溯源 + */ + market_data?: (MarketData | null); + /** + * Bible 与参考资产 + */ + references?: (References | null); + /** + * 后期叠加计划 + */ + post_production?: (PostProduction | null); +}; + diff --git a/front/src/services/generated/models/FactCard.ts b/front/src/services/generated/models/FactCard.ts new file mode 100644 index 00000000..f35523b3 --- /dev/null +++ b/front/src/services/generated/models/FactCard.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { FactCardLocalizedCopy } from './FactCardLocalizedCopy'; +/** + * 后期 fact card;**永远不是第五个生成镜头**。 + */ +export type FactCard = { + /** + * 卡片时长(毫秒) + */ + duration_ms: number; + /** + * 追加式才计入总时长 + */ + placement?: 'append_after_shots' | 'overlay_tail'; + /** + * 卡面文字一律后期合成 + */ + readable_text_in_post?: boolean; + /** + * 各语言文案 + */ + localized: Array; +}; + diff --git a/front/src/services/generated/models/FactCardLocalizedCopy.ts b/front/src/services/generated/models/FactCardLocalizedCopy.ts new file mode 100644 index 00000000..d600ad87 --- /dev/null +++ b/front/src/services/generated/models/FactCardLocalizedCopy.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * fact card 的单语言文案。 + */ +export type FactCardLocalizedCopy = { + /** + * BCP 47 语言标签 + */ + language_tag: string; + /** + * 教育性正文行(每行非空) + */ + body: Array; + /** + * 免责声明(非空) + */ + disclaimer: string; + /** + * 可选 CTA + */ + cta?: (string | null); +}; + diff --git a/front/src/services/generated/models/FileTypeEnum.ts b/front/src/services/generated/models/FileTypeEnum.ts index 75f4e2b8..67d528bd 100644 --- a/front/src/services/generated/models/FileTypeEnum.ts +++ b/front/src/services/generated/models/FileTypeEnum.ts @@ -2,4 +2,4 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type FileTypeEnum = 'image' | 'video'; +export type FileTypeEnum = 'image' | 'video' | 'subtitle'; diff --git a/front/src/services/generated/models/ImportCounts.ts b/front/src/services/generated/models/ImportCounts.ts new file mode 100644 index 00000000..c2e88a5e --- /dev/null +++ b/front/src/services/generated/models/ImportCounts.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 各类实体的计数(created 或 reused 各一份)。 + */ +export type ImportCounts = { + chapters?: number; + shots?: number; + shot_details?: number; + dialog_lines?: number; + characters?: number; + actors?: number; + scenes?: number; + props?: number; + costumes?: number; + links?: number; +}; + diff --git a/front/src/services/generated/models/ImportEpisodeRequest.ts b/front/src/services/generated/models/ImportEpisodeRequest.ts new file mode 100644 index 00000000..22baf102 --- /dev/null +++ b/front/src/services/generated/models/ImportEpisodeRequest.ts @@ -0,0 +1,28 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EpisodePackage } from './EpisodePackage'; +import type { EpisodePackageV11 } from './EpisodePackageV11'; +/** + * POST /api/v1/crypto-animal-studio/import 的请求体。 + */ +export type ImportEpisodeRequest = { + /** + * 目标 Jellyfish 项目 ID(系列/季) + */ + project_id: string; + /** + * 待导入的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1) + */ + episode_package: (EpisodePackageV11 | EpisodePackage); + /** + * 为真时只校验/映射/复用查找/告警,不写库 + */ + dry_run?: boolean; + /** + * 幂等键 + */ + idempotency_key: string; +}; + diff --git a/front/src/services/generated/models/ImportResult.ts b/front/src/services/generated/models/ImportResult.ts new file mode 100644 index 00000000..32f147d0 --- /dev/null +++ b/front/src/services/generated/models/ImportResult.ts @@ -0,0 +1,55 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ImportCounts } from './ImportCounts'; +import type { SubtitleArtifact } from './SubtitleArtifact'; +/** + * 一次导入(或 dry-run / 重放)的结果摘要。 + */ +export type ImportResult = { + /** + * imported | dry_run | replayed + */ + status: string; + /** + * 是否为 dry-run(未写库) + */ + dry_run: boolean; + /** + * 是否命中幂等重放(返回既有结果) + */ + idempotent_replay: boolean; + project_id: string; + episode_id: string; + idempotency_key: string; + /** + * EpisodePackage 规范化 SHA-256 + */ + payload_hash: string; + /** + * 产生/既有的 Chapter ID;dry-run 为 null + */ + chapter_id?: (string | null); + /** + * Chapter 在项目内的序号;dry-run 为拟用序号 + */ + chapter_index?: (number | null); + /** + * 本次新建计数 + */ + created?: ImportCounts; + /** + * 本次复用计数 + */ + reused?: ImportCounts; + /** + * 非阻断告警(不丢弃数据) + */ + warnings?: Array; + /** + * 本次导入生成/复用的字幕产物(WebVTT);v1 文档为空列表 + */ + subtitle_artifacts?: Array; +}; + diff --git a/front/src/services/generated/models/Localization.ts b/front/src/services/generated/models/Localization.ts new file mode 100644 index 00000000..c97523c3 --- /dev/null +++ b/front/src/services/generated/models/Localization.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SubtitleTrack } from './SubtitleTrack'; +/** + * 口语与字幕本地化。字幕结构上可选;必需语言只来自 required_publish_language_tags。 + */ +export type Localization = { + /** + * 对白语言(缺省回落到根 language) + */ + spoken_language?: (string | null); + /** + * 发布前必须具备字幕的语言标签;空表示无要求 + */ + required_publish_language_tags?: Array; + /** + * 字幕轨列表 + */ + subtitle_tracks?: Array; +}; + diff --git a/front/src/services/generated/models/MarketData.ts b/front/src/services/generated/models/MarketData.ts new file mode 100644 index 00000000..8ec9f7f1 --- /dev/null +++ b/front/src/services/generated/models/MarketData.ts @@ -0,0 +1,67 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DataLock } from './DataLock'; +/** + * 市场事实溯源。数值刻意为「可含占位符的字符串」(最小化 v1.1 折衷)。 + */ +export type MarketData = { + /** + * 标的,如 BTC-USD + */ + instrument: string; + /** + * 确认所用周期,如 4h + */ + timeframe: string; + /** + * 被突破的阻力位 + */ + resistance_level?: (string | null); + /** + * 事件时价格 + */ + price?: (string | null); + /** + * 区间涨跌幅 + */ + price_move_pct?: (string | null); + /** + * 回撤幅度 + */ + pullback_pct?: (string | null); + /** + * 事件时间 + */ + event_timestamp_utc?: (string | null); + /** + * 确认K棒收盘时间 + */ + candle_close_timestamp_utc?: (string | null); + /** + * 数据 as-of 时间 + */ + as_of_utc?: (string | null); + /** + * 数据来源名称 + */ + source_name?: (string | null); + /** + * 公开溯源 URL(仅证据,非执行端点) + */ + source_url?: (string | null); + /** + * 人工核对备注 + */ + factual_note?: (string | null); + /** + * 可选前高背景 + */ + ath_context?: (string | null); + /** + * 锁定状态 + */ + data_lock?: DataLock; +}; + diff --git a/front/src/services/generated/models/NewsSource.ts b/front/src/services/generated/models/NewsSource.ts new file mode 100644 index 00000000..5ea1d5d7 --- /dev/null +++ b/front/src/services/generated/models/NewsSource.ts @@ -0,0 +1,36 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 一集的素材来源:新闻或原创设定的事实性上下文。 + * + * 仅承载「事实/触发点」,不含创意执行;便于追溯与审核。 + */ +export type NewsSource = { + /** + * 来源类型:news/original/fictional/generic + */ + source_type: 'news' | 'original' | 'fictional' | 'generic'; + /** + * 标题(新闻标题或原创触发点标题) + */ + headline?: string; + /** + * 摘要:事件的中性概述 + */ + summary?: string; + /** + * 来源链接(可选;原创内容可为空) + */ + source_url?: (string | null); + /** + * 发布时间(ISO-8601 字符串,可选) + */ + published_at?: (string | null); + /** + * 事实性备注:不得改写为投资建议或价格预测 + */ + factual_notes?: string; +}; + diff --git a/front/src/services/generated/models/OutputSpec.ts b/front/src/services/generated/models/OutputSpec.ts new file mode 100644 index 00000000..ebd3a9f3 --- /dev/null +++ b/front/src/services/generated/models/OutputSpec.ts @@ -0,0 +1,43 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SafeArea } from './SafeArea'; +/** + * 输出规格;``*_ms`` 断言永不覆盖派生时长。 + */ +export type OutputSpec = { + /** + * 画面比例,形如 W:H + */ + aspect_ratio?: string; + /** + * 渲染宽度(像素) + */ + width?: number; + /** + * 渲染高度(像素) + */ + height?: number; + /** + * 帧率 + */ + fps?: number; + /** + * 画面方向 + */ + orientation?: 'vertical' | 'horizontal' | 'square'; + /** + * 生成footage总毫秒(可选断言) + */ + generated_footage_ms?: (number | null); + /** + * 最终成片总毫秒(可选断言) + */ + total_runtime_ms?: (number | null); + /** + * 安全区元数据 + */ + safe_area?: SafeArea; +}; + diff --git a/front/src/services/generated/models/OverlayLocalizedText.ts b/front/src/services/generated/models/OverlayLocalizedText.ts new file mode 100644 index 00000000..f6c89d2d --- /dev/null +++ b/front/src/services/generated/models/OverlayLocalizedText.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 叠加图形的单语言文案。 + */ +export type OverlayLocalizedText = { + /** + * BCP 47 语言标签 + */ + language_tag: string; + /** + * 文案 + */ + text: string; +}; + diff --git a/front/src/services/generated/models/PostProduction.ts b/front/src/services/generated/models/PostProduction.ts new file mode 100644 index 00000000..5a91a186 --- /dev/null +++ b/front/src/services/generated/models/PostProduction.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { PostProductionOverlay } from './PostProductionOverlay'; +/** + * 后期叠加计划:所有可读金融文字都在这里,不进入生成画面。 + */ +export type PostProduction = { + /** + * 叠加列表 + */ + overlays?: Array; +}; + diff --git a/front/src/services/generated/models/PostProductionOverlay.ts b/front/src/services/generated/models/PostProductionOverlay.ts new file mode 100644 index 00000000..800387d3 --- /dev/null +++ b/front/src/services/generated/models/PostProductionOverlay.ts @@ -0,0 +1,43 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { OverlayLocalizedText } from './OverlayLocalizedText'; +/** + * 后期叠加图形;时间为 episode-absolute 毫秒,shot_id 仅作关联。 + */ +export type PostProductionOverlay = { + /** + * 稳定 ID(被 shots[].overlay_ids 引用) + */ + overlay_id: string; + /** + * 叠加类型 + */ + type: 'chart_label' | 'subtitle' | 'notification' | 'fact_card' | 'disclaimer' | 'cta' | 'other'; + /** + * 关联镜头(null 表示 episode 级) + */ + shot_id?: (string | null); + /** + * 入点(episode-absolute 毫秒) + */ + start_ms?: (number | null); + /** + * 出点(episode-absolute 毫秒) + */ + end_ms?: (number | null); + /** + * 是否必需(可选叠加允许省略) + */ + required?: boolean; + /** + * 安全区锚点 + */ + anchor?: 'lower_safe' | 'upper_safe' | 'centre' | 'prop_local'; + /** + * 各语言文案 + */ + localized?: Array; +}; + diff --git a/front/src/services/generated/models/ProductionArtifactView.ts b/front/src/services/generated/models/ProductionArtifactView.ts new file mode 100644 index 00000000..cd713acf --- /dev/null +++ b/front/src/services/generated/models/ProductionArtifactView.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 产物视图。 + */ +export type ProductionArtifactView = { + id: string; + production_shot_id: (string | null); + artifact_type: string; + stage: string; + provider: string; + provider_model: string; + file_path: string; + mime_type: string; + checksum: string; +}; + diff --git a/front/src/services/generated/models/ProductionJobView.ts b/front/src/services/generated/models/ProductionJobView.ts new file mode 100644 index 00000000..e3b2a4c5 --- /dev/null +++ b/front/src/services/generated/models/ProductionJobView.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ProductionShotView } from './ProductionShotView'; +/** + * 生产任务视图。 + */ +export type ProductionJobView = { + id: string; + project_id: string; + episode_id: string; + status: string; + current_stage: string; + provider_mode: string; + episode_package_hash: string; + output_path: string; + error_message: string; + started_at?: (string | null); + completed_at?: (string | null); + shots?: Array; + manifest_path?: (string | null); + final_output?: (string | null); +}; + diff --git a/front/src/services/generated/models/ProductionShotView.ts b/front/src/services/generated/models/ProductionShotView.ts new file mode 100644 index 00000000..ee2cc1e9 --- /dev/null +++ b/front/src/services/generated/models/ProductionShotView.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 生产镜头视图。 + */ +export type ProductionShotView = { + id: string; + source_shot_id: string; + sequence: number; + status: string; + current_stage: string; + duration_seconds: number; + error_message: string; +}; + diff --git a/front/src/services/generated/models/PropAsset.ts b/front/src/services/generated/models/PropAsset.ts new file mode 100644 index 00000000..ad8a7197 --- /dev/null +++ b/front/src/services/generated/models/PropAsset.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 道具素材。 + */ +export type PropAsset = { + /** + * 道具键(素材类别内唯一) + */ + prop_key: string; + /** + * 展示名 + */ + display_name?: string; + /** + * 道具描述 + */ + description?: string; +}; + diff --git a/front/src/services/generated/models/ReferenceAsset.ts b/front/src/services/generated/models/ReferenceAsset.ts new file mode 100644 index 00000000..4dbabe9a --- /dev/null +++ b/front/src/services/generated/models/ReferenceAsset.ts @@ -0,0 +1,38 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 一条参考资产:稳定 asset_id + 可选仓库相对路径(禁止供应商 URL)。 + */ +export type ReferenceAsset = { + /** + * 角色键(角色参考用) + */ + character_key?: (string | null); + /** + * 场景键(环境参考用) + */ + scene_key?: (string | null); + /** + * 道具键(道具参考用) + */ + prop_key?: (string | null); + /** + * 稳定不透明资产 ID + */ + asset_id: string; + /** + * 不可变身份参考 vs 本集专用 + */ + kind?: 'identity' | 'episode'; + /** + * 视角提示,如 front + */ + view?: (string | null); + /** + * 仓库相对路径;**不得**为供应商 URL + */ + path?: (string | null); +}; + diff --git a/front/src/services/generated/models/References.ts b/front/src/services/generated/models/References.ts new file mode 100644 index 00000000..de6f87a7 --- /dev/null +++ b/front/src/services/generated/models/References.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ReferenceAsset } from './ReferenceAsset'; +/** + * Bible 版本与参考资产集合。 + */ +export type References = { + /** + * Bible 版本,如 1.0 + */ + bible_version?: (string | null); + /** + * 治理决策,如 ADR-015 + */ + canon_decision?: (string | null); + /** + * 角色参考 + */ + characters?: Array; + /** + * 环境参考 + */ + environments?: Array; + /** + * 道具参考 + */ + props?: Array; +}; + diff --git a/front/src/services/generated/models/RegenerationFallback.ts b/front/src/services/generated/models/RegenerationFallback.ts new file mode 100644 index 00000000..ff9f5f31 --- /dev/null +++ b/front/src/services/generated/models/RegenerationFallback.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CasCameraMovement } from './CasCameraMovement'; +/** + * 重生成兜底(仅恢复手段,不是同等生产选项)。 + */ +export type RegenerationFallback = { + /** + * 兜底运镜(复用既有枚举) + */ + camera_movement?: (CasCameraMovement | null); + /** + * 适用条件说明 + */ + note?: string; +}; + diff --git a/front/src/services/generated/models/RetryProductionJobRequest.ts b/front/src/services/generated/models/RetryProductionJobRequest.ts new file mode 100644 index 00000000..28645d5c --- /dev/null +++ b/front/src/services/generated/models/RetryProductionJobRequest.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EpisodePackage } from './EpisodePackage'; +import type { EpisodePackageV11 } from './EpisodePackageV11'; +/** + * POST /production/jobs/{job_id}/retry 请求体。 + */ +export type RetryProductionJobRequest = { + /** + * 与原任务一致的 EpisodePackage(用于重跑;接受 schema_version 1.0 或 1.1) + */ + episode_package: (EpisodePackageV11 | EpisodePackage); + /** + * 供应商模式;本冲刺仅支持 mock + */ + mode?: string; +}; + diff --git a/front/src/services/generated/models/SafeArea.ts b/front/src/services/generated/models/SafeArea.ts new file mode 100644 index 00000000..ac50755e --- /dev/null +++ b/front/src/services/generated/models/SafeArea.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 安全区元数据(百分比)。 + */ +export type SafeArea = { + /** + * 字幕安全带(画面底部百分比) + */ + subtitle_bottom_pct?: number; + /** + * 通用安全边距(百分比) + */ + margin_pct?: number; +}; + diff --git a/front/src/services/generated/models/SceneAsset.ts b/front/src/services/generated/models/SceneAsset.ts new file mode 100644 index 00000000..25d6b9fd --- /dev/null +++ b/front/src/services/generated/models/SceneAsset.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 场景素材。 + */ +export type SceneAsset = { + /** + * 场景键(素材类别内唯一) + */ + scene_key: string; + /** + * 展示名 + */ + display_name?: string; + /** + * 场景描述 + */ + description?: string; +}; + diff --git a/front/src/services/generated/models/Shot.ts b/front/src/services/generated/models/Shot.ts new file mode 100644 index 00000000..e544300e --- /dev/null +++ b/front/src/services/generated/models/Shot.ts @@ -0,0 +1,85 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CameraSpec } from './CameraSpec'; +import type { DialogueLine } from './DialogueLine'; +/** + * 一个镜头(storyboard 中的 shot),直接映射为 Jellyfish 的 Shot/ShotDetail。 + * + * 说明: + * - ``camera`` 为结构化对象(``CameraSpec``),字段对齐 Jellyfish ShotDetail 的 + * camera_shot/angle/movement,便于后续导入器映射;取值由 CAS 本地枚举校验。 + * - ``duration_seconds`` 允许小数,必须大于零。 + */ +export type Shot = { + /** + * 镜头 ID(本集内唯一,非空) + */ + shot_id: string; + /** + * 镜头顺序(正整数,本集内唯一) + */ + sequence: number; + /** + * 镜头标题/分镜名 + */ + title?: string; + /** + * 镜头时长(秒),必须大于零 + */ + duration_seconds: number; + /** + * 镜头对应的剧本摘录 + */ + script_excerpt?: string; + /** + * 结构化相机描述(景别/角度/运镜,可选) + */ + camera?: (CameraSpec | null); + /** + * 镜头内动作/视觉描述 + */ + action?: string; + /** + * 镜头内对白列表 + */ + dialogue?: Array; + /** + * 出场角色键(须存在于 characters) + */ + character_keys?: Array; + /** + * 场景键(可选;提供则须存在于 assets.scenes) + */ + scene_key?: (string | null); + /** + * 道具键(须存在于 assets.props) + */ + prop_keys?: Array; + /** + * 服装键(须存在于 assets.costumes) + */ + costume_keys?: Array; + /** + * 图像生成提示词 + */ + image_prompt?: string; + /** + * 视频生成提示词 + */ + video_prompt?: string; + /** + * 反向提示词 + */ + negative_prompt?: string; + /** + * 镜头连续性备注 + */ + continuity_notes?: string; + /** + * 镜头级附加元信息 + */ + metadata?: Record; +}; + diff --git a/front/src/services/generated/models/ShotV11.ts b/front/src/services/generated/models/ShotV11.ts new file mode 100644 index 00000000..34d94817 --- /dev/null +++ b/front/src/services/generated/models/ShotV11.ts @@ -0,0 +1,104 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CameraSpec } from './CameraSpec'; +import type { DialogueLine } from './DialogueLine'; +import type { RegenerationFallback } from './RegenerationFallback'; +/** + * v1.1 镜头:在 v1 ``Shot`` 之上仅新增五个可选字段。 + * + * 刻意不新增:连续性字段(用 ``continuity_notes``)、运镜字段(用 ``camera.movement``)、 + * 任何镜头相对时间字段。 + */ +export type ShotV11 = { + /** + * 镜头 ID(本集内唯一,非空) + */ + shot_id: string; + /** + * 镜头顺序(正整数,本集内唯一) + */ + sequence: number; + /** + * 镜头标题/分镜名 + */ + title?: string; + /** + * 镜头时长(秒),必须大于零 + */ + duration_seconds: number; + /** + * 镜头对应的剧本摘录 + */ + script_excerpt?: string; + /** + * 结构化相机描述(景别/角度/运镜,可选) + */ + camera?: (CameraSpec | null); + /** + * 镜头内动作/视觉描述 + */ + action?: string; + /** + * 镜头内对白列表 + */ + dialogue?: Array; + /** + * 出场角色键(须存在于 characters) + */ + character_keys?: Array; + /** + * 场景键(可选;提供则须存在于 assets.scenes) + */ + scene_key?: (string | null); + /** + * 道具键(须存在于 assets.props) + */ + prop_keys?: Array; + /** + * 服装键(须存在于 assets.costumes) + */ + costume_keys?: Array; + /** + * 图像生成提示词 + */ + image_prompt?: string; + /** + * 视频生成提示词 + */ + video_prompt?: string; + /** + * 反向提示词 + */ + negative_prompt?: string; + /** + * 镜头连续性备注 + */ + continuity_notes?: string; + /** + * 镜头级附加元信息 + */ + metadata?: Record; + /** + * 起始状态(生成用) + */ + beginning_state?: string; + /** + * 结束状态(生成用) + */ + ending_state?: string; + /** + * 已知生成风险 + */ + generation_risks?: Array; + /** + * 仅恢复用兜底方案 + */ + regeneration_fallback?: (RegenerationFallback | null); + /** + * 关联的后期叠加 ID + */ + overlay_ids?: Array; +}; + diff --git a/front/src/services/generated/models/SubtitleArtifact.ts b/front/src/services/generated/models/SubtitleArtifact.ts new file mode 100644 index 00000000..2a7c31cc --- /dev/null +++ b/front/src/services/generated/models/SubtitleArtifact.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 一条字幕产物(WebVTT)在导入结果中的表示。 + */ +export type SubtitleArtifact = { + /** + * Jellyfish files.id + */ + file_id: string; + /** + * BCP 47 语言标签,如 zh-Hant + */ + language_tag: string; + /** + * 对象存储 key(确定性) + */ + storage_key: string; + /** + * cue 数量 + */ + cue_count: number; + /** + * WebVTT 字节数 + */ + byte_size: number; + /** + * true=本次新建;false=复用既有产物并就地更新 + */ + created: boolean; +}; + diff --git a/front/src/services/generated/models/SubtitleCue.ts b/front/src/services/generated/models/SubtitleCue.ts new file mode 100644 index 00000000..099da10e --- /dev/null +++ b/front/src/services/generated/models/SubtitleCue.ts @@ -0,0 +1,34 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 字幕单条 cue;时间为 episode-absolute 整数毫秒。 + */ +export type SubtitleCue = { + /** + * cue 稳定 ID(轨内唯一) + */ + cue_id: string; + /** + * 入点(episode-absolute 毫秒) + */ + start_ms: number; + /** + * 出点(必须大于 start_ms) + */ + end_ms: number; + /** + * 译文(非空) + */ + text: string; + /** + * 说话角色键(须存在于 characters) + */ + speaker_character_key?: (string | null); + /** + * 关联镜头(仅关联,不构成第二套时间真相) + */ + shot_id?: (string | null); +}; + diff --git a/front/src/services/generated/models/SubtitleTrack.ts b/front/src/services/generated/models/SubtitleTrack.ts new file mode 100644 index 00000000..2c1645ff --- /dev/null +++ b/front/src/services/generated/models/SubtitleTrack.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { SubtitleCue } from './SubtitleCue'; +/** + * 一条字幕轨。渲染默认属于后期,不进入 AI 生成。 + */ +export type SubtitleTrack = { + /** + * BCP 47 语言标签,如 zh-Hant + */ + language_tag: string; + /** + * 是否为主轨 + */ + is_primary?: boolean; + /** + * 渲染方式(声明性;默认后期) + */ + rendering?: 'post_production' | 'burned_in' | 'sidecar'; + /** + * cue 列表(可为空,但后期阶段起视为无效) + */ + cues: Array; +}; + diff --git a/front/src/services/generated/services/CryptoAnimalStudioProductionService.ts b/front/src/services/generated/services/CryptoAnimalStudioProductionService.ts new file mode 100644 index 00000000..dd431697 --- /dev/null +++ b/front/src/services/generated/services/CryptoAnimalStudioProductionService.ts @@ -0,0 +1,104 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiResponse_list_ProductionArtifactView__ } from '../models/ApiResponse_list_ProductionArtifactView__'; +import type { ApiResponse_ProductionJobView_ } from '../models/ApiResponse_ProductionJobView_'; +import type { CreateProductionJobRequest } from '../models/CreateProductionJobRequest'; +import type { RetryProductionJobRequest } from '../models/RetryProductionJobRequest'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class CryptoAnimalStudioProductionService { + /** + * Create Production Job + * 创建并同步执行一次生产(每次调用创建新任务)。 + * @returns ApiResponse_ProductionJobView_ Successful Response + * @throws ApiError + */ + public static createProductionJobApiV1CryptoAnimalStudioProductionJobsPost({ + requestBody, + }: { + requestBody: CreateProductionJobRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/crypto-animal-studio/production/jobs', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Production Job + * 查询生产任务状态。 + * @returns ApiResponse_ProductionJobView_ Successful Response + * @throws ApiError + */ + public static getProductionJobApiV1CryptoAnimalStudioProductionJobsJobIdGet({ + jobId, + }: { + jobId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/crypto-animal-studio/production/jobs/{job_id}', + path: { + 'job_id': jobId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * List Production Artifacts + * 列出任务的全部产物。 + * @returns ApiResponse_list_ProductionArtifactView__ Successful Response + * @throws ApiError + */ + public static listProductionArtifactsApiV1CryptoAnimalStudioProductionJobsJobIdArtifactsGet({ + jobId, + }: { + jobId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/crypto-animal-studio/production/jobs/{job_id}/artifacts', + path: { + 'job_id': jobId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Retry Production Job + * 从失败阶段重试(复用更早的有效产物)。 + * @returns ApiResponse_ProductionJobView_ Successful Response + * @throws ApiError + */ + public static retryProductionJobApiV1CryptoAnimalStudioProductionJobsJobIdRetryPost({ + jobId, + requestBody, + }: { + jobId: string, + requestBody: RetryProductionJobRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/crypto-animal-studio/production/jobs/{job_id}/retry', + path: { + 'job_id': jobId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/front/src/services/generated/services/CryptoAnimalStudioService.ts b/front/src/services/generated/services/CryptoAnimalStudioService.ts new file mode 100644 index 00000000..717bad42 --- /dev/null +++ b/front/src/services/generated/services/CryptoAnimalStudioService.ts @@ -0,0 +1,175 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiResponse_CasImportTaskAccepted_ } from '../models/ApiResponse_CasImportTaskAccepted_'; +import type { ApiResponse_dict_ } from '../models/ApiResponse_dict_'; +import type { ApiResponse_ImportResult_ } from '../models/ApiResponse_ImportResult_'; +import type { ApiResponse_list_ProductionArtifactView__ } from '../models/ApiResponse_list_ProductionArtifactView__'; +import type { ApiResponse_ProductionJobView_ } from '../models/ApiResponse_ProductionJobView_'; +import type { CreateProductionJobRequest } from '../models/CreateProductionJobRequest'; +import type { ImportEpisodeRequest } from '../models/ImportEpisodeRequest'; +import type { RetryProductionJobRequest } from '../models/RetryProductionJobRequest'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class CryptoAnimalStudioService { + /** + * Cas Health + * 返回 CAS 模块健康状态与契约版本。 + * + * 返回: + * 统一 ``ApiResponse`` 壳,data 形如 + * ``{"service": "crypto-animal-studio", "status": "ok", "schema_version": "1.0"}``。 + * @returns ApiResponse_dict_ Successful Response + * @throws ApiError + */ + public static casHealthApiV1CryptoAnimalStudioHealthGet(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/crypto-animal-studio/health', + }); + } + /** + * Import Episode Endpoint + * 导入一个 EpisodePackage 为一个 Jellyfish Chapter(含 Shots 等)。 + * + * 返回:统一 ``ApiResponse``,data 为 ImportResult。 + * 错误:项目不存在→404;幂等冲突/重复导入→409;契约校验失败→422(由 pydantic); + * CAS QA 闸门失败→422(零写入)。 + * @returns ApiResponse_ImportResult_ Successful Response + * @throws ApiError + */ + public static importEpisodeEndpointApiV1CryptoAnimalStudioImportPost({ + requestBody, + }: { + requestBody: ImportEpisodeRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/crypto-animal-studio/import', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Import Episode Async Endpoint + * 把导入登记为任务中心的 ``cas_import_episode_package`` 任务并立即返回。 + * + * 请求体与同步端点完全一致(同一个 ``ImportEpisodeRequest``),因此契约校验行为不变。 + * 真正的导入由 ``run_cas_import_task`` 驱动,成功/失败通过既有任务状态查询接口获取。 + * + * 返回:统一 ``ApiResponse``,data 为任务受理信息(``reused=true`` 表示复用活动任务)。 + * @returns ApiResponse_CasImportTaskAccepted_ Successful Response + * @throws ApiError + */ + public static importEpisodeAsyncEndpointApiV1CryptoAnimalStudioImportAsyncPost({ + requestBody, + }: { + requestBody: ImportEpisodeRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/crypto-animal-studio/import/async', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Create Production Job + * 创建并同步执行一次生产(每次调用创建新任务)。 + * @returns ApiResponse_ProductionJobView_ Successful Response + * @throws ApiError + */ + public static createProductionJobApiV1CryptoAnimalStudioProductionJobsPost({ + requestBody, + }: { + requestBody: CreateProductionJobRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/crypto-animal-studio/production/jobs', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Production Job + * 查询生产任务状态。 + * @returns ApiResponse_ProductionJobView_ Successful Response + * @throws ApiError + */ + public static getProductionJobApiV1CryptoAnimalStudioProductionJobsJobIdGet({ + jobId, + }: { + jobId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/crypto-animal-studio/production/jobs/{job_id}', + path: { + 'job_id': jobId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * List Production Artifacts + * 列出任务的全部产物。 + * @returns ApiResponse_list_ProductionArtifactView__ Successful Response + * @throws ApiError + */ + public static listProductionArtifactsApiV1CryptoAnimalStudioProductionJobsJobIdArtifactsGet({ + jobId, + }: { + jobId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/crypto-animal-studio/production/jobs/{job_id}/artifacts', + path: { + 'job_id': jobId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Retry Production Job + * 从失败阶段重试(复用更早的有效产物)。 + * @returns ApiResponse_ProductionJobView_ Successful Response + * @throws ApiError + */ + public static retryProductionJobApiV1CryptoAnimalStudioProductionJobsJobIdRetryPost({ + jobId, + requestBody, + }: { + jobId: string, + requestBody: RetryProductionJobRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/crypto-animal-studio/production/jobs/{job_id}/retry', + path: { + 'job_id': jobId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/front/src/services/generated/services/StudioFilesService.ts b/front/src/services/generated/services/StudioFilesService.ts index 1123fb66..21cda5c8 100644 --- a/front/src/services/generated/services/StudioFilesService.ts +++ b/front/src/services/generated/services/StudioFilesService.ts @@ -27,6 +27,8 @@ export class StudioFilesService { projectId, chapterTitle, shotTitle, + chapterId, + usageKind, }: { /** * 关键字,过滤 name @@ -48,6 +50,14 @@ export class StudioFilesService { * 镜头标题(精确匹配,与 project_id 联用) */ shotTitle?: (string | null), + /** + * 按 file_usages.chapter_id 精确过滤(与 project_id 联用;比标题稳定) + */ + chapterId?: (string | null), + /** + * 按 file_usages.usage_kind 精确过滤,如 subtitle(与 project_id 联用) + */ + usageKind?: (string | null), }): CancelablePromise { return __request(OpenAPI, { method: 'GET', @@ -61,6 +71,8 @@ export class StudioFilesService { 'project_id': projectId, 'chapter_title': chapterTitle, 'shot_title': shotTitle, + 'chapter_id': chapterId, + 'usage_kind': usageKind, }, errors: { 422: `Validation Error`, diff --git a/front/src/setupTests.ts b/front/src/setupTests.ts new file mode 100644 index 00000000..f69fb81a --- /dev/null +++ b/front/src/setupTests.ts @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom/vitest' +import { afterEach, vi } from 'vitest' +import { cleanup } from '@testing-library/react' + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +// antd 组件依赖 matchMedia,jsdom 未实现。 +if (!window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), + }) +} + +// jsdom 未实现带伪元素参数的 getComputedStyle,会抛 +// "Not implemented: window.getComputedStyle(elt, pseudoElt)"。 +// 两处受影响: +// 1. antd 的 rc-table 在挂载时测量滚动条宽度 → 大量噪声堆栈; +// 2. Testing Library 计算可访问名称(getByRole 的 name 选项)时会读取伪元素生成内容, +// 失败后名称算不出来,导致按名称查询 button 找不到元素。 +// 这里丢弃伪元素参数并回退到元素自身样式:jsdom 本就不支持伪元素样式, +// 该退化不会掩盖任何真实的产品缺陷。 +const originalGetComputedStyle = window.getComputedStyle.bind(window) +window.getComputedStyle = ((element: Element, pseudoElement?: string | null) => + pseudoElement + ? originalGetComputedStyle(element) + : originalGetComputedStyle(element)) as typeof window.getComputedStyle + +// jsdom 不实现 URL.createObjectURL(字幕下载会用到)。 +if (!URL.createObjectURL) { + URL.createObjectURL = () => 'blob:mock' + URL.revokeObjectURL = () => undefined +} diff --git a/front/vitest.config.ts b/front/vitest.config.ts new file mode 100644 index 00000000..c6c16c93 --- /dev/null +++ b/front/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +// 独立于 vite.config.ts:保持生产构建配置不受测试配置影响。 +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/setupTests.ts'], + css: false, + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, +}) diff --git a/samples/cas/demo_episode.json b/samples/cas/demo_episode.json new file mode 100644 index 00000000..485840bc --- /dev/null +++ b/samples/cas/demo_episode.json @@ -0,0 +1,169 @@ +{ + "schema_version": "1.0", + "episode_id": "CAS-E001", + "title": "Champagne Before Confirmation", + "logline": "Bull celebrates a rumored inflow record before anyone has actually confirmed it.", + "language": "en", + "source": { + "source_type": "fictional", + "headline": "Fictional Exchange Reports Record Inflows Into Animal Token Fund", + "summary": "A made-up trading desk claims an unusually strong day of inflows into a fictional fund. Nothing is confirmed and no real market data is referenced.", + "source_url": null, + "published_at": "2026-01-02T09:00:00Z", + "factual_notes": "Entirely fictional scenario for demonstration. Not investment advice; no price prediction; no reliance on live facts." + }, + "creative_direction": { + "format": "short_form_vertical", + "tone": "deadpan", + "target_duration_seconds": 45, + "visual_style": "anime", + "comedy_style": "false_confidence + callback", + "continuity_notes": "Keep Walter dry and sparse. The champagne is a running visual gag across the episode." + }, + "characters": [ + { + "character_key": "bull", + "display_name": "Bull", + "role": "main", + "description": "Relentlessly optimistic trader who celebrates first and checks later.", + "actor_key": "actor_bull", + "costume_key": "costume_trader_vest", + "voice_profile": "loud, upbeat", + "continuity_notes": "Always holding something celebratory." + }, + { + "character_key": "bear", + "display_name": "Bear", + "role": "straight_man", + "description": "Skeptical and tired; expects the worst on principle.", + "actor_key": "actor_bear", + "costume_key": "costume_hoodie", + "voice_profile": "low, weary", + "continuity_notes": "Rarely makes eye contact with the celebration." + }, + { + "character_key": "walter", + "display_name": "Walter", + "role": "deadpan_anchor", + "description": "Quiet, dry closer who delivers the final understated line.", + "actor_key": "actor_walter", + "costume_key": null, + "voice_profile": "flat, minimal", + "continuity_notes": "Speaks at most once; the punchline lands on his silence." + } + ], + "assets": { + "actors": [ + { "actor_key": "actor_bull", "display_name": "Bull", "description": "Anthropomorphic bull, broad-shouldered, expressive." }, + { "actor_key": "actor_bear", "display_name": "Bear", "description": "Anthropomorphic bear, slouched posture." }, + { "actor_key": "actor_walter", "display_name": "Walter", "description": "Small, neat, unreadable expression." } + ], + "scenes": [ + { "scene_key": "scene_trading_floor", "display_name": "Trading Floor", "description": "Open office with monitors, a doom board, and a snack table." } + ], + "props": [ + { "prop_key": "prop_champagne", "display_name": "Champagne Bottle", "description": "Oversized celebratory bottle that keeps reappearing." }, + { "prop_key": "prop_phone", "display_name": "Phone", "description": "Screen shows an unconfirmed notification." } + ], + "costumes": [ + { "costume_key": "costume_trader_vest", "display_name": "Trader Vest", "description": "Bright vest with too many pockets." }, + { "costume_key": "costume_hoodie", "display_name": "Grey Hoodie", "description": "Worn, oversized, hood usually up." } + ] + }, + "shots": [ + { + "shot_id": "SC01", + "sequence": 1, + "title": "The premature toast", + "duration_seconds": 8, + "script_excerpt": "Bull bursts in with the champagne before anyone can react.", + "camera": { "shot_type": "MS", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Bull kicks the door open, champagne raised over his head.", + "dialogue": [ + { "order": 1, "character_key": "bull", "text": "Record day! We are so back!", "line_mode": "DIALOGUE" }, + { "order": 2, "character_key": "bear", "text": "Back from what.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_champagne"], + "costume_keys": ["costume_trader_vest", "costume_hoodie"], + "image_prompt": "trading floor, bull raising a champagne bottle, bear slouched in background, anime, cel-shaded", + "video_prompt": "bull kicks door open and raises champagne, bear does not look up, 8 seconds", + "negative_prompt": "no real logos, no real ticker symbols, no text overlays", + "continuity_notes": "Establish the champagne gag here.", + "metadata": { "beat": "hook" } + }, + { + "shot_id": "SC02", + "sequence": 2, + "title": "The unread notification", + "duration_seconds": 10, + "script_excerpt": "Bear points at the phone; the number is unconfirmed.", + "camera": { "shot_type": "CU", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Bear slides the phone across the desk; the screen says 'PENDING'.", + "dialogue": [ + { "order": 1, "character_key": "bear", "text": "It literally says pending.", "line_mode": "DIALOGUE" }, + { "order": 2, "character_key": "bull", "text": "Pending is basically confirmed.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_phone"], + "costume_keys": ["costume_trader_vest", "costume_hoodie"], + "image_prompt": "close up of a phone reading PENDING, bull leaning in confidently, anime", + "video_prompt": "bear slides phone forward, bull waves it off, 10 seconds", + "negative_prompt": "no real app UI, no real brand names", + "continuity_notes": "Callback target for the ending.", + "metadata": { "beat": "trigger" } + }, + { + "shot_id": "SC03", + "sequence": 3, + "title": "The escalation", + "duration_seconds": 12, + "script_excerpt": "Bull pours anyway; Bear braces for the correction.", + "camera": { "shot_type": "MLS", "angle": "LOW_ANGLE", "movement": "HANDHELD" }, + "action": "Bull pops the champagne; foam goes everywhere; Bear covers his monitor.", + "dialogue": [ + { "order": 1, "character_key": "bull", "text": "To confirmation that definitely will happen!", "line_mode": "DIALOGUE" }, + { "order": 2, "character_key": "bear", "text": "You are cleaning that up.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_champagne"], + "costume_keys": ["costume_trader_vest"], + "image_prompt": "champagne foam exploding across a trading desk, bear shielding a monitor, anime, dynamic", + "video_prompt": "champagne pops, foam sprays, bear ducks, 12 seconds", + "negative_prompt": "no real market charts, no numbers on screens", + "continuity_notes": "Payoff of the champagne gag from SC01.", + "metadata": { "beat": "escalation" } + }, + { + "shot_id": "SC04", + "sequence": 4, + "title": "The quiet correction", + "duration_seconds": 9, + "script_excerpt": "Walter reads the phone and says one thing.", + "camera": { "shot_type": "CU", "angle": "EYE_LEVEL", "movement": "STATIC" }, + "action": "Walter picks up the phone, glances at it, sets it face down. Beat. He walks off.", + "dialogue": [ + { "order": 1, "character_key": "walter", "text": "It was rounding.", "line_mode": "DIALOGUE" } + ], + "character_keys": ["walter", "bull", "bear"], + "scene_key": "scene_trading_floor", + "prop_keys": ["prop_phone"], + "costume_keys": [], + "image_prompt": "walter setting a phone face down on a foam-covered desk, deadpan, anime", + "video_prompt": "walter reads phone, sets it down, walks off, freeze on bull's face, 9 seconds", + "negative_prompt": "no explanatory text, no captions after the line", + "continuity_notes": "Punchline lands on Walter's exit; do not add an explaining beat after this.", + "metadata": { "beat": "punchline" } + } + ], + "metadata": { + "created_at": "2026-01-02T09:05:00Z", + "generator": "creative-os", + "model": "sample-fixture", + "prompt_version": "cas-episode-v1", + "tags": ["sample", "fictional", "crypto-animal-studio"] + } +} diff --git a/samples/cas/ep001_btc_breakout.json b/samples/cas/ep001_btc_breakout.json new file mode 100644 index 00000000..5819ff19 --- /dev/null +++ b/samples/cas/ep001_btc_breakout.json @@ -0,0 +1,555 @@ +{ + "schema_version": "1.1", + "episode_id": "CAS-EP001", + "title": "BTC Breaks Out — Bruno Celebrates Too Early", + "logline": "Bitcoin pushes above resistance and Bruno throws a party on the signal alone.", + "language": "en", + "source": { + "source_type": "news", + "headline": "BTC moves above a prior resistance area", + "summary": "Price moved above a prior resistance area; confirmation still outstanding.", + "source_url": "https://example-exchange.test/markets/btc-usd", + "published_at": "2026-01-02T09:00:00Z", + "factual_notes": "Initial breakout signal only; not a confirmed breakout." + }, + "creative_direction": { + "format": "short_form_vertical", + "tone": "deadpan", + "target_duration_seconds": 24, + "visual_style": "premium stylized 3D", + "comedy_style": "personality collision", + "continuity_notes": "Bible v1 locked identities (Bruno Bull, Boris Bear, Milo Cat); world Block Street; The Burrow layout fixed per EP001 §8." + }, + "characters": [ + { + "character_key": "bruno_bull", + "display_name": "Bruno Bull", + "role": "momentum trader", + "description": "Anthropomorphic bull; chestnut-brown fur; forest-green rolled-sleeve shirt; mustard tie; black smartwatch on left wrist.", + "actor_key": "actor_bruno", + "costume_key": "costume_bruno_office", + "voice_profile": "warm baritone, energetic", + "continuity_notes": "Tallest of the trio. No jacket, hat, or glasses." + }, + { + "character_key": "boris_bear", + "display_name": "Boris Bear", + "role": "risk manager", + "description": "Anthropomorphic bear; charcoal-brown fur; burgundy knit vest over pale blue shirt; rectangular black reading glasses.", + "actor_key": "actor_boris", + "costume_key": "costume_boris_office", + "voice_profile": "low, controlled", + "continuity_notes": "Wider than Milo; red notebook and red pen." + }, + { + "character_key": "milo_cat", + "display_name": "Milo Cat", + "role": "strategist", + "description": "Anthropomorphic burnt-orange tabby; three forehead stripes; dark teal turtleneck; silver Bitcoin pin on left chest.", + "actor_key": "actor_milo", + "costume_key": "costume_milo_office", + "voice_profile": "smooth, understated", + "continuity_notes": "Shortest of the trio. Matte black mug." + } + ], + "assets": { + "actors": [ + { + "actor_key": "actor_bruno", + "display_name": "Bruno", + "description": "Bull identity plate." + }, + { + "actor_key": "actor_boris", + "display_name": "Boris", + "description": "Bear identity plate." + }, + { + "actor_key": "actor_milo", + "display_name": "Milo", + "description": "Cat identity plate." + } + ], + "scenes": [ + { + "scene_key": "the_burrow", + "display_name": "The Burrow", + "description": "Trading studio: curved desk, wall BTC chart, coffee station, glass wall." + } + ], + "props": [ + { + "prop_key": "wall_btc_chart", + "display_name": "Wall BTC chart", + "description": "Abstract, textless chart plate." + }, + { + "prop_key": "milo_phone", + "display_name": "Phone", + "description": "Supporting screen prop; glow only, no legible text." + } + ], + "costumes": [ + { + "costume_key": "costume_bruno_office", + "display_name": "Bruno office", + "description": "Forest-green shirt, mustard tie." + }, + { + "costume_key": "costume_boris_office", + "display_name": "Boris office", + "description": "Burgundy vest, pale blue shirt." + }, + { + "costume_key": "costume_milo_office", + "display_name": "Milo office", + "description": "Dark teal turtleneck." + } + ] + }, + "shots": [ + { + "shot_id": "SC01", + "sequence": 1, + "title": "The premature toast", + "duration_seconds": 3.0, + "script_excerpt": "Bruno bursts in as the alert flares.", + "camera": { + "shot_type": "MS", + "angle": "EYE_LEVEL", + "movement": "DOLLY_IN" + }, + "action": "Bruno shoulders through the doorway, arms rising.", + "dialogue": [ + { + "order": 1, + "character_key": "bruno_bull", + "text": "Breakout! We are so back!", + "line_mode": "DIALOGUE" + } + ], + "character_keys": [ + "bruno_bull", + "boris_bear" + ], + "scene_key": "the_burrow", + "prop_keys": [ + "wall_btc_chart" + ], + "costume_keys": [ + "costume_bruno_office" + ], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Forest-green shirt; watch on left wrist; symmetrical horns.", + "metadata": { + "beat": "hook" + }, + "beginning_state": "Door half-open; chart line crossing the level.", + "ending_state": "Bruno fully in frame, arms up; green accent lit.", + "generation_risks": [ + "extra or asymmetric horns", + "jacket appearing", + "legible chart text" + ], + "regeneration_fallback": null, + "overlay_ids": [ + "ov_chart_label_01" + ] + }, + { + "shot_id": "SC02", + "sequence": 2, + "title": "Confirmation, please", + "duration_seconds": 7.0, + "script_excerpt": "Boris blocks the celebration.", + "camera": { + "shot_type": "MCU", + "angle": "EYE_LEVEL", + "movement": "STATIC" + }, + "action": "Boris raises a flat paw, clutching the red notebook.", + "dialogue": [ + { + "order": 1, + "character_key": "boris_bear", + "text": "The candle hasn't closed yet.", + "line_mode": "DIALOGUE" + } + ], + "character_keys": [ + "boris_bear", + "bruno_bull" + ], + "scene_key": "the_burrow", + "prop_keys": [], + "costume_keys": [ + "costume_boris_office" + ], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Glasses present; vest burgundy; small rounded ears.", + "metadata": { + "beat": "conflict" + }, + "beginning_state": "Boris mid-turn from his monitor.", + "ending_state": "Paw up, notebook chest-high.", + "generation_risks": [ + "glasses disappearing", + "vest colour drift" + ], + "regeneration_fallback": null, + "overlay_ids": [] + }, + { + "shot_id": "SC03", + "sequence": 3, + "title": "The dip", + "duration_seconds": 6.5, + "script_excerpt": "The chart dips; Bruno freezes.", + "camera": { + "shot_type": "MLS", + "angle": "EYE_LEVEL", + "movement": "HANDHELD" + }, + "action": "Bruno freezes mid-celebration; papers hang in the air.", + "dialogue": [ + { + "order": 1, + "character_key": "bruno_bull", + "text": "It's still green… right?", + "line_mode": "DIALOGUE" + } + ], + "character_keys": [ + "bruno_bull", + "boris_bear" + ], + "scene_key": "the_burrow", + "prop_keys": [ + "wall_btc_chart" + ], + "costume_keys": [], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Resistance line at the same screen height as SC01.", + "metadata": { + "beat": "escalation" + }, + "beginning_state": "Celebration at maximum; chart at local high.", + "ending_state": "Bruno statue-still; chart visibly lower.", + "generation_risks": [ + "duplicate characters", + "full-frame red wash", + "wardrobe change" + ], + "regeneration_fallback": null, + "overlay_ids": [ + "ov_chart_label_02" + ] + }, + { + "shot_id": "SC04", + "sequence": 4, + "title": "Before the close", + "duration_seconds": 4.5, + "script_excerpt": "Milo lowers his mug and reveals the delivery.", + "camera": { + "shot_type": "CU", + "angle": "EYE_LEVEL", + "movement": "PAN" + }, + "action": "Milo lowers the mug, glances at his phone, slow blink.", + "dialogue": [ + { + "order": 1, + "character_key": "milo_cat", + "text": "Your confetti arrives before candle close.", + "line_mode": "DIALOGUE" + } + ], + "character_keys": [ + "milo_cat", + "bruno_bull", + "boris_bear" + ], + "scene_key": "the_burrow", + "prop_keys": [ + "milo_phone" + ], + "costume_keys": [ + "costume_milo_office" + ], + "image_prompt": "", + "video_prompt": "", + "negative_prompt": "", + "continuity_notes": "Teal turtleneck; pin on left chest; phone glow only, no legible text.", + "metadata": { + "beat": "punchline" + }, + "beginning_state": "Mug at lips; phone face-up, screen glow only.", + "ending_state": "Mug at chest height; gaze level; tail settled.", + "generation_risks": [ + "legible phone text", + "stripe or eye-colour drift", + "glasses on Milo" + ], + "regeneration_fallback": { + "camera_movement": "STATIC", + "note": "Recovery only if identity drifts during the pan." + }, + "overlay_ids": [ + "ov_phone_notification" + ] + } + ], + "metadata": { + "created_at": "2026-01-02T09:05:00Z", + "generator": "cas-episode-design", + "model": "", + "prompt_version": "ep001-v1.1", + "tags": [ + "ep001", + "production", + "bible-v1", + "block-street" + ] + }, + "output": { + "aspect_ratio": "9:16", + "width": 1080, + "height": 1920, + "fps": 30, + "orientation": "vertical", + "generated_footage_ms": 21000, + "total_runtime_ms": 24000, + "safe_area": { + "subtitle_bottom_pct": 18, + "margin_pct": 6 + } + }, + "localization": { + "spoken_language": "en", + "required_publish_language_tags": [ + "zh-Hant" + ], + "subtitle_tracks": [ + { + "language_tag": "zh-Hant", + "is_primary": true, + "rendering": "post_production", + "cues": [ + { + "cue_id": "c1", + "start_ms": 400, + "end_ms": 2000, + "text": "突破了!我們回來了!", + "speaker_character_key": "bruno_bull", + "shot_id": "SC01" + }, + { + "cue_id": "c2", + "start_ms": 3400, + "end_ms": 5400, + "text": "這根K棒還沒收。", + "speaker_character_key": "boris_bear", + "shot_id": "SC02" + }, + { + "cue_id": "c3", + "start_ms": 11000, + "end_ms": 12800, + "text": "還是綠的……對吧?", + "speaker_character_key": "bruno_bull", + "shot_id": "SC03" + }, + { + "cue_id": "c4", + "start_ms": 17200, + "end_ms": 19600, + "text": "你的彩帶會比收盤先到。", + "speaker_character_key": "milo_cat", + "shot_id": "SC04" + } + ] + } + ] + }, + "fact_card": { + "duration_ms": 3000, + "placement": "append_after_shots", + "readable_text_in_post": true, + "localized": [ + { + "language_tag": "en", + "body": [ + "Moving above 71,500 is the initial breakout signal — not a confirmed breakout.", + "Some traders wait for the 4h candle to close above it and follow through.", + "Confirmation criteria don't guarantee future performance." + ], + "disclaimer": "For education and entertainment, not financial advice.", + "cta": null + }, + { + "language_tag": "zh-Hant", + "body": [ + "價格站上 71,500 只是初步突破訊號,不等於已確認突破。", + "部分交易者會等 4h K棒收在其上並延續。", + "確認條件並不保證未來表現。" + ], + "disclaimer": "僅供教育與娛樂,非投資建議。", + "cta": null + } + ] + }, + "market_data": { + "instrument": "BTC-USD", + "timeframe": "4h", + "resistance_level": "71,500.00", + "price": "$71,842.10", + "price_move_pct": "2.4%", + "pullback_pct": "-0.8%", + "event_timestamp_utc": "2026-01-02T08:40:00Z", + "candle_close_timestamp_utc": "2026-01-02T12:00:00Z", + "as_of_utc": "2026-01-02T08:45:00Z", + "source_name": "Example Exchange (test)", + "source_url": "https://example-exchange.test/markets/btc-usd", + "factual_note": "Price moved above the level; confirmation outstanding at capture time.", + "ath_context": null, + "data_lock": { + "status": "locked", + "locked_at_utc": "2026-01-02T08:50:00Z" + } + }, + "references": { + "bible_version": "1.0", + "canon_decision": "ADR-015", + "characters": [ + { + "character_key": "bruno_bull", + "scene_key": null, + "prop_key": null, + "asset_id": "cas/bruno/identity/front", + "kind": "identity", + "view": "front", + "path": "assets/cas/bruno/front.png" + }, + { + "character_key": "boris_bear", + "scene_key": null, + "prop_key": null, + "asset_id": "cas/boris/identity/front", + "kind": "identity", + "view": "front", + "path": "assets/cas/boris/front.png" + }, + { + "character_key": "milo_cat", + "scene_key": null, + "prop_key": null, + "asset_id": "cas/milo/identity/front", + "kind": "identity", + "view": "front", + "path": "assets/cas/milo/front.png" + } + ], + "environments": [ + { + "character_key": null, + "scene_key": "the_burrow", + "prop_key": null, + "asset_id": "cas/env/the_burrow/master_wide", + "kind": "identity", + "view": null, + "path": null + } + ], + "props": [ + { + "character_key": null, + "scene_key": null, + "prop_key": "wall_btc_chart", + "asset_id": "cas/prop/wall_chart/textless", + "kind": "identity", + "view": null, + "path": null + } + ] + }, + "post_production": { + "overlays": [ + { + "overlay_id": "ov_chart_label_01", + "type": "chart_label", + "shot_id": "SC01", + "start_ms": 600, + "end_ms": 3000, + "required": false, + "anchor": "upper_safe", + "localized": [ + { + "language_tag": "en", + "text": "71,500" + } + ] + }, + { + "overlay_id": "ov_chart_label_02", + "type": "chart_label", + "shot_id": "SC03", + "start_ms": 10200, + "end_ms": 13000, + "required": false, + "anchor": "upper_safe", + "localized": [ + { + "language_tag": "en", + "text": "-0.8%" + } + ] + }, + { + "overlay_id": "ov_phone_notification", + "type": "notification", + "shot_id": "SC04", + "start_ms": 17000, + "end_ms": 19000, + "required": false, + "anchor": "prop_local", + "localized": [ + { + "language_tag": "en", + "text": "Delivery arriving" + }, + { + "language_tag": "zh-Hant", + "text": "外送即將送達" + } + ] + }, + { + "overlay_id": "ov_fact_card", + "type": "fact_card", + "shot_id": null, + "start_ms": 21000, + "end_ms": 24000, + "required": true, + "anchor": "centre", + "localized": [] + }, + { + "overlay_id": "ov_disclaimer", + "type": "disclaimer", + "shot_id": null, + "start_ms": 21000, + "end_ms": 24000, + "required": true, + "anchor": "lower_safe", + "localized": [] + } + ] + } +} From 2f5f11a2c0c479749e2165ba6e837fcb46dbdedc Mon Sep 17 00:00:00 2001 From: shiuyu Date: Thu, 30 Jul 2026 21:00:46 +0800 Subject: [PATCH 4/5] feat(cas): complete Step 7 production render workspace --- backend/app/config.py | 12 + backend/app/core/contracts/provider.py | 5 +- .../app/core/integrations/comfyui/__init__.py | 27 + .../app/core/integrations/comfyui/video.py | 166 +++++++ .../app/core/integrations/comfyui/workflow.py | 172 +++++++ backend/app/core/tasks/bootstrap.py | 1 + .../app/core/tasks/video_generation_tasks.py | 166 ++++++- .../crypto_animal_studio/api/production.py | 133 ++++- .../application/render_request.py | 169 +++++++ .../application/render_tasks.py | 403 +++++++++++++++ .../application/render_views.py | 133 +++++ .../application/shot_render.py | 287 +++++++++++ .../schemas/production.py | 47 +- backend/app/services/worker/task_registry.py | 13 + .../fixtures/comfyui/example_mapping.json | 13 + .../comfyui/example_workflow.api.json | 8 + backend/tests/test_cas_render_api.py | 327 ++++++++++++ backend/tests/test_cas_render_e2e.py | 466 ++++++++++++++++++ backend/tests/test_cas_render_request.py | 122 +++++ backend/tests/test_cas_render_tasks.py | 317 ++++++++++++ backend/tests/test_cas_render_views.py | 215 ++++++++ backend/tests/test_comfyui_provider.py | 244 +++++++++ docs/implementation-log.md | 92 ++++ front/pnpm-lock.yaml | 2 +- .../cas/Ep001Workspace.render.test.tsx | 149 ++++++ .../src/pages/aiStudio/cas/Ep001Workspace.tsx | 58 +++ .../aiStudio/cas/ShotRenderPanel.test.tsx | 288 +++++++++++ .../pages/aiStudio/cas/ShotRenderPanel.tsx | 262 ++++++++++ front/src/services/casWorkspaceApi.ts | 153 ++++++ 29 files changed, 4428 insertions(+), 22 deletions(-) create mode 100644 backend/app/core/integrations/comfyui/__init__.py create mode 100644 backend/app/core/integrations/comfyui/video.py create mode 100644 backend/app/core/integrations/comfyui/workflow.py create mode 100644 backend/app/crypto_animal_studio/application/render_request.py create mode 100644 backend/app/crypto_animal_studio/application/render_tasks.py create mode 100644 backend/app/crypto_animal_studio/application/render_views.py create mode 100644 backend/app/crypto_animal_studio/application/shot_render.py create mode 100644 backend/tests/fixtures/comfyui/example_mapping.json create mode 100644 backend/tests/fixtures/comfyui/example_workflow.api.json create mode 100644 backend/tests/test_cas_render_api.py create mode 100644 backend/tests/test_cas_render_e2e.py create mode 100644 backend/tests/test_cas_render_request.py create mode 100644 backend/tests/test_cas_render_tasks.py create mode 100644 backend/tests/test_cas_render_views.py create mode 100644 backend/tests/test_comfyui_provider.py create mode 100644 front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx create mode 100644 front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx create mode 100644 front/src/pages/aiStudio/cas/ShotRenderPanel.tsx diff --git a/backend/app/config.py b/backend/app/config.py index f6be6ce9..8b81dbd2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,6 +62,18 @@ def cors_origins_list(self) -> list[str]: # 可选:对外访问基址(CDN 或自定义域名),为空则使用 S3 自带 URL 或预签名 URL s3_public_base_url: str | None = None + # CAS 单镜头渲染(Step 7)。全部通过环境变量提供,仓库内不存放任何地址或凭据。 + #: 渲染使用的供应商标识:comfyui | volcengine | openai。 + cas_render_provider: str = "comfyui" + #: ComfyUI 实例地址,例如 http://127.0.0.1:8188(无缺省值:未配置即明确失败)。 + cas_comfyui_base_url: str | None = None + #: 工作流「输入/输出映射」JSON 的路径,见 app.core.integrations.comfyui.workflow。 + cas_comfyui_workflow_mapping: str | None = None + #: 轮询间隔(秒)。 + cas_render_poll_interval_s: float = 3.0 + #: 单次渲染超时(秒)。视频生成通常远慢于图像,缺省给足余量。 + cas_render_timeout_s: float = 1800.0 + def model_post_init(self, __context: object) -> None: if not self.celery_broker_url or not str(self.celery_broker_url).strip(): password_part = f":{self.redis_password}@" if self.redis_password else "" diff --git a/backend/app/core/contracts/provider.py b/backend/app/core/contracts/provider.py index 209efd5f..8ab51484 100644 --- a/backend/app/core/contracts/provider.py +++ b/backend/app/core/contracts/provider.py @@ -5,7 +5,10 @@ from dataclasses import dataclass from typing import Literal -ProviderKey = Literal["openai", "volcengine"] +#: 受支持的供应商标识。 +#: ``comfyui`` 为自托管推理服务:无 API key,凭 base_url 直连,见 +#: ``app.core.integrations.comfyui``。 +ProviderKey = Literal["openai", "volcengine", "comfyui"] @dataclass(frozen=True, slots=True) diff --git a/backend/app/core/integrations/comfyui/__init__.py b/backend/app/core/integrations/comfyui/__init__.py new file mode 100644 index 00000000..84798911 --- /dev/null +++ b/backend/app/core/integrations/comfyui/__init__.py @@ -0,0 +1,27 @@ +"""ComfyUI 集成(自托管推理服务)。""" + +from app.core.integrations.comfyui.video import ( + ComfyUIError, + ComfyUIVideoApiAdapter, + extract_video_output, + is_video_filename, + read_execution_status, +) +from app.core.integrations.comfyui.workflow import ( + WorkflowConfigError, + WorkflowMapping, + apply_inputs, + load_mapping, +) + +__all__ = [ + "ComfyUIError", + "ComfyUIVideoApiAdapter", + "WorkflowConfigError", + "WorkflowMapping", + "apply_inputs", + "extract_video_output", + "is_video_filename", + "load_mapping", + "read_execution_status", +] diff --git a/backend/app/core/integrations/comfyui/video.py b/backend/app/core/integrations/comfyui/video.py new file mode 100644 index 00000000..0feb74a8 --- /dev/null +++ b/backend/app/core/integrations/comfyui/video.py @@ -0,0 +1,166 @@ +"""ComfyUI:以其常规 HTTP API 提交工作流、查询历史、定位并取回视频产物。 + +约定与其他 adapter 一致:本模块只做 HTTP 与响应解析,轮询节奏由 Task 层控制。 + +ComfyUI 是自托管服务,通常无 API key;``ProviderConfig.base_url`` 即实例地址。 +不使用浏览器自动化。 +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlencode + +#: 视为「视频」的产物后缀(ComfyUI 常见视频节点输出)。 +VIDEO_SUFFIXES: tuple[str, ...] = (".mp4", ".webm", ".mov", ".mkv", ".gif") + +#: history 里可能承载产物列表的键(不同视频节点命名不一)。 +_OUTPUT_COLLECTION_KEYS: tuple[str, ...] = ("videos", "gifs", "images", "files") + + +class ComfyUIError(RuntimeError): + """ComfyUI 交互失败(结构化,供上层映射为任务失败原因)。""" + + +def _require_httpx(): + """延迟导入 httpx,保持与既有 adapter 相同的失败语义。""" + try: + import httpx + except ImportError as exc: # pragma: no cover - 环境缺依赖 + raise ComfyUIError("httpx is required for ComfyUI video generation") from exc + return httpx + + +def _base_url(cfg: Any) -> str: + """取实例地址;缺失时明确报错,绝不猜测机器地址。""" + base = (getattr(cfg, "base_url", None) or "").strip() + if not base: + raise ComfyUIError( + "ComfyUI base_url is not configured; set it in the provider configuration" + ) + return base.rstrip("/") + + +def is_video_filename(filename: str) -> bool: + """按后缀判断是否为受支持的视频产物。""" + lowered = (filename or "").lower() + return lowered.endswith(VIDEO_SUFFIXES) + + +def extract_video_output(history_entry: dict[str, Any], output_node: str) -> dict[str, str]: + """从 history 条目中定位输出节点的视频产物。 + + 返回 ``{"filename", "subfolder", "type"}``。 + 找不到视频产物时抛 ComfyUIError —— 不允许把非视频结果当作成功。 + """ + outputs = history_entry.get("outputs") + if not isinstance(outputs, dict): + raise ComfyUIError("ComfyUI history entry has no 'outputs'") + node_output = outputs.get(output_node) + if not isinstance(node_output, dict): + raise ComfyUIError(f"ComfyUI history has no output for node {output_node!r}") + + for key in _OUTPUT_COLLECTION_KEYS: + items = node_output.get(key) + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + filename = str(item.get("filename") or "") + if filename and is_video_filename(filename): + return { + "filename": filename, + "subfolder": str(item.get("subfolder") or ""), + "type": str(item.get("type") or "output"), + } + raise ComfyUIError( + f"node {output_node!r} produced no video output " + f"(supported suffixes: {', '.join(VIDEO_SUFFIXES)})" + ) + + +class ComfyUIVideoApiAdapter: + """ComfyUI 视频工作流 HTTP。""" + + async def submit_prompt( + self, + *, + cfg: Any, + prompt: dict[str, Any], + client_id: str, + timeout_s: float, + ) -> str: + """提交工作流,返回 ``prompt_id``。""" + httpx = _require_httpx() + base = _base_url(cfg) + body = {"prompt": prompt, "client_id": client_id} + async with httpx.AsyncClient(timeout=timeout_s) as client: + response = await client.post(f"{base}/prompt", json=body) + if response.status_code >= 400: + # ComfyUI 会在 400 里返回节点级校验错误,对诊断很有价值。 + raise ComfyUIError( + f"ComfyUI rejected the workflow (HTTP {response.status_code}): " + f"{response.text[:500]}" + ) + data: dict[str, Any] = response.json() + prompt_id = str(data.get("prompt_id") or "") + if not prompt_id: + raise ComfyUIError(f"ComfyUI /prompt returned no prompt_id: {data!r}") + return prompt_id + + async def get_history(self, *, cfg: Any, prompt_id: str, timeout_s: float) -> dict[str, Any] | None: + """查询某次执行的历史;尚未产生记录时返回 None(表示仍在排队/执行)。""" + httpx = _require_httpx() + base = _base_url(cfg) + async with httpx.AsyncClient(timeout=timeout_s) as client: + response = await client.get(f"{base}/history/{prompt_id}") + if response.status_code == 404: + return None + response.raise_for_status() + data: dict[str, Any] = response.json() + entry = data.get(prompt_id) + return entry if isinstance(entry, dict) else None + + def build_view_url(self, *, cfg: Any, output: dict[str, str]) -> str: + """拼出产物下载地址(``/view``)。""" + base = _base_url(cfg) + query = urlencode( + { + "filename": output.get("filename", ""), + "subfolder": output.get("subfolder", ""), + "type": output.get("type", "output"), + } + ) + return f"{base}/view?{query}" + + +def read_execution_status(entry: dict[str, Any]) -> tuple[str, str]: + """把 history 条目解析为 ``(status, message)``。 + + ComfyUI 的 ``status.status_str`` 常见值为 ``success`` / ``error``; + 没有该字段时按「仍在执行」处理。 + """ + status = entry.get("status") + if not isinstance(status, dict): + return "running", "" + status_str = str(status.get("status_str") or "").lower() + if status_str == "error" or status.get("completed") is False and status_str: + messages = status.get("messages") + detail = "" + if isinstance(messages, list) and messages: + detail = str(messages[-1])[:500] + return "error", detail or "ComfyUI reported an execution error" + if status_str == "success" or status.get("completed") is True: + return "success", "" + return "running", "" + + +__all__ = [ + "VIDEO_SUFFIXES", + "ComfyUIError", + "ComfyUIVideoApiAdapter", + "extract_video_output", + "is_video_filename", + "read_execution_status", +] diff --git a/backend/app/core/integrations/comfyui/workflow.py b/backend/app/core/integrations/comfyui/workflow.py new file mode 100644 index 00000000..1eace425 --- /dev/null +++ b/backend/app/core/integrations/comfyui/workflow.py @@ -0,0 +1,172 @@ +"""ComfyUI API-format 工作流的加载与输入注入。 + +**不假设任何节点 ID。** 节点 ID 因工作流而异,因此本模块要求显式提供一份 +「输入映射」,把逻辑输入名(prompt / width / ...)映射到 +``.``,并显式指定输出节点。 + +映射文件(JSON)示例:: + + { + "workflow_path": "workflows/cas_txt2video.api.json", + "inputs": { + "positive_prompt": "6.text", + "negative_prompt": "7.text", + "width": "5.width", + "height": "5.height", + "frames": "5.length", + "fps": "8.fps", + "seed": "3.seed" + }, + "output_node": "9" + } + +只有出现在 ``inputs`` 中的键才会被注入;未映射的输入被静默忽略(因为并非所有 +工作流都支持 negative prompt / fps / seed)。这样既避免了硬编码,也避免了向 +工作流写入它不认识的字段。 +""" + +from __future__ import annotations + +import copy +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +#: 允许注入的逻辑输入名。刻意保持窄集合:新增需显式扩展并测试。 +SUPPORTED_INPUT_KEYS: frozenset[str] = frozenset( + {"positive_prompt", "negative_prompt", "width", "height", "frames", "fps", "seed"} +) + + +class WorkflowConfigError(RuntimeError): + """工作流配置缺失或不合法。 + + 该错误必须清晰暴露到 Production Job 与工作台,不能被静默吞掉, + 也不能退化成假供应商。 + """ + + +@dataclass(frozen=True, slots=True) +class WorkflowMapping: + """一份工作流及其输入/输出映射。""" + + workflow: dict[str, Any] + inputs: dict[str, str] + output_node: str + + def describe(self) -> dict[str, Any]: + """用于诊断的安全摘要(不含提示词内容与任何密钥)。""" + return { + "node_count": len(self.workflow), + "mapped_inputs": sorted(self.inputs.keys()), + "output_node": self.output_node, + } + + +def _split_target(target: str) -> tuple[str, str]: + """把 ``"6.text"`` 拆成 ``("6", "text")``。""" + node_id, _, field = target.partition(".") + if not node_id or not field: + raise WorkflowConfigError( + f"invalid input mapping target {target!r}; expected '.'" + ) + return node_id, field + + +def load_mapping(mapping_path: str | Path, *, base_dir: str | Path | None = None) -> WorkflowMapping: + """读取映射文件与其引用的工作流 JSON。 + + 参数: + mapping_path: 映射 JSON 的路径。 + base_dir: 解析 ``workflow_path`` 相对路径的基准目录;缺省用映射文件所在目录。 + 异常: + WorkflowConfigError:文件缺失、JSON 非法、字段缺失或映射目标不合法。 + """ + path = Path(mapping_path) + if not path.is_file(): + raise WorkflowConfigError(f"workflow mapping file not found: {path}") + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise WorkflowConfigError(f"workflow mapping is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise WorkflowConfigError("workflow mapping must be a JSON object") + + workflow_rel = raw.get("workflow_path") + if not isinstance(workflow_rel, str) or not workflow_rel.strip(): + raise WorkflowConfigError("workflow mapping requires a non-empty 'workflow_path'") + + root = Path(base_dir) if base_dir is not None else path.parent + workflow_file = Path(workflow_rel) + if not workflow_file.is_absolute(): + workflow_file = root / workflow_file + if not workflow_file.is_file(): + raise WorkflowConfigError(f"workflow file not found: {workflow_file}") + try: + workflow = json.loads(workflow_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise WorkflowConfigError(f"workflow file is not valid JSON: {exc}") from exc + if not isinstance(workflow, dict) or not workflow: + raise WorkflowConfigError("workflow must be a non-empty API-format JSON object") + + inputs = raw.get("inputs") + if not isinstance(inputs, dict) or not inputs: + raise WorkflowConfigError("workflow mapping requires a non-empty 'inputs' object") + unknown = sorted(set(inputs) - SUPPORTED_INPUT_KEYS) + if unknown: + raise WorkflowConfigError( + f"unsupported input keys in mapping: {unknown}; " + f"supported: {sorted(SUPPORTED_INPUT_KEYS)}" + ) + + normalized: dict[str, str] = {} + for key, target in inputs.items(): + if not isinstance(target, str): + raise WorkflowConfigError(f"input mapping for {key!r} must be a string") + node_id, _field = _split_target(target) + if node_id not in workflow: + raise WorkflowConfigError( + f"input mapping for {key!r} references node {node_id!r} absent from the workflow" + ) + normalized[key] = target + + output_node = raw.get("output_node") + if not isinstance(output_node, str) or not output_node.strip(): + raise WorkflowConfigError("workflow mapping requires a non-empty 'output_node'") + if output_node not in workflow: + raise WorkflowConfigError(f"output_node {output_node!r} is absent from the workflow") + + return WorkflowMapping(workflow=workflow, inputs=normalized, output_node=output_node) + + +def apply_inputs(mapping: WorkflowMapping, values: dict[str, Any]) -> dict[str, Any]: + """把 ``values`` 注入工作流副本并返回。 + + 只注入「既被映射、又在 values 里有非 None 值」的键;原始工作流不被修改。 + """ + prompt = copy.deepcopy(mapping.workflow) + for key, target in mapping.inputs.items(): + if key not in values: + continue + value = values[key] + if value is None: + continue + node_id, field = _split_target(target) + node = prompt.get(node_id) + if not isinstance(node, dict): + raise WorkflowConfigError(f"workflow node {node_id!r} is not an object") + node.setdefault("inputs", {}) + if not isinstance(node["inputs"], dict): + raise WorkflowConfigError(f"workflow node {node_id!r} has a non-object 'inputs'") + node["inputs"][field] = value + return prompt + + +__all__ = [ + "SUPPORTED_INPUT_KEYS", + "WorkflowConfigError", + "WorkflowMapping", + "apply_inputs", + "load_mapping", +] diff --git a/backend/app/core/tasks/bootstrap.py b/backend/app/core/tasks/bootstrap.py index 942f5334..cec6ba23 100644 --- a/backend/app/core/tasks/bootstrap.py +++ b/backend/app/core/tasks/bootstrap.py @@ -12,6 +12,7 @@ ("image_generation", "volcengine", ImageGenerationTask._build_volcengine_impl), ("video_generation", "openai", VideoGenerationTask._build_openai_impl), ("video_generation", "volcengine", VideoGenerationTask._build_volcengine_impl), + ("video_generation", "comfyui", VideoGenerationTask._build_comfyui_impl), ) diff --git a/backend/app/core/tasks/video_generation_tasks.py b/backend/app/core/tasks/video_generation_tasks.py index 885203d0..5ecab88e 100644 --- a/backend/app/core/tasks/video_generation_tasks.py +++ b/backend/app/core/tasks/video_generation_tasks.py @@ -6,8 +6,10 @@ from __future__ import annotations import asyncio +import time +import uuid from abc import ABC, abstractmethod -from typing import Any, AsyncIterator +from typing import TYPE_CHECKING, Any, AsyncIterator from app.core.integrations.openai.video import OpenAIVideoApiAdapter from app.core.integrations.volcengine.video import VolcengineVideoApiAdapter @@ -16,15 +18,40 @@ from app.core.contracts.video_generation import VideoGenerationInput, VideoGenerationResult from app.core.task_manager.types import BaseTask +if TYPE_CHECKING: # pragma: no cover - 仅供类型检查 + from app.core.integrations.comfyui import ComfyUIVideoApiAdapter, WorkflowMapping + __all__ = [ "VideoGenerationInput", "VideoGenerationResult", "AbstractVideoGenerationTask", "OpenAIVideoGenerationTask", "VolcengineVideoGenerationTask", + "ComfyUIVideoGenerationTask", "VideoGenerationTask", ] +#: ComfyUI 工作流缺省帧率(用于把 seconds 换算为 frames)。 +_DEFAULT_FPS = 24 + +#: 各宽高比对应的渲染分辨率。9:16 与 EP001 的 1080×1920 输出规格一致。 +_RATIO_DIMENSIONS: dict[str, tuple[int, int]] = { + "9:16": (1080, 1920), + "16:9": (1920, 1080), + "1:1": (1024, 1024), + "4:3": (1440, 1080), + "3:4": (1080, 1440), + "21:9": (2560, 1080), +} + + +def _dimensions_for_ratio(ratio: str) -> tuple[int, int]: + """把业务侧的宽高比解析为像素宽高。""" + try: + return _RATIO_DIMENSIONS[ratio] + except KeyError as exc: # pragma: no cover - 契约已限制取值 + raise RuntimeError(f"no dimensions configured for ratio {ratio!r}") from exc + class AbstractVideoGenerationTask(BaseTask, ABC): """视频生成任务基类:公共状态与 run/status/is_done/get_result。""" @@ -206,8 +233,124 @@ async def _poll_and_get_result(self) -> VideoGenerationResult: ) +class ComfyUIVideoGenerationTask(AbstractVideoGenerationTask): + """ComfyUI 自托管工作流:提交 prompt → 轮询 history → 定位视频产物。 + + 工作流与节点映射来自配置(见 ``app.core.integrations.comfyui.workflow``), + 不在代码里假设任何节点 ID。轮询受 ``timeout_s`` 约束,超时映射为结构化失败。 + """ + + def __init__( + self, + *, + adapter: "ComfyUIVideoApiAdapter | None" = None, + mapping: "WorkflowMapping | None" = None, + provider_config: ProviderConfig, + input_: VideoGenerationInput, + poll_interval_s: float = 2.0, + timeout_s: float = 120.0, + workflow_mapping_path: str | None = None, + client_id: str | None = None, + ) -> None: + super().__init__( + provider_config=provider_config, + input_=input_, + poll_interval_s=poll_interval_s, + timeout_s=timeout_s, + ) + from app.core.integrations.comfyui import ( # 局部导入:保持模块导入开销不变 + ComfyUIVideoApiAdapter as _Adapter, + WorkflowConfigError, + load_mapping, + ) + + self._adapter = adapter or _Adapter() + self._client_id = client_id or f"jellyfish-cas-{uuid.uuid4().hex[:12]}" + self._output: dict[str, str] | None = None + if mapping is not None: + self._mapping = mapping + else: + path = (workflow_mapping_path or "").strip() + if not path: + # 配置缺失必须清晰失败,绝不退化到假供应商。 + raise WorkflowConfigError( + "ComfyUI workflow mapping path is not configured " + "(set CAS_COMFYUI_WORKFLOW_MAPPING)" + ) + self._mapping = load_mapping(path) + + def _build_workflow_values(self) -> dict[str, Any]: + """把统一的 VideoGenerationInput 映射为工作流输入值。""" + from app.core.integrations.video_capabilities import ALLOWED_RATIOS + + width, height = _dimensions_for_ratio(self._input.ratio) + values: dict[str, Any] = { + "positive_prompt": (self._input.prompt or "").strip(), + "width": width, + "height": height, + } + if self._input.seed is not None and self._input.seed >= 0: + values["seed"] = self._input.seed + if self._input.seconds is not None and self._input.seconds > 0: + fps = _DEFAULT_FPS + values["fps"] = fps + values["frames"] = int(self._input.seconds * fps) + if self._input.ratio not in ALLOWED_RATIOS: # pragma: no cover - 契约已限制 + raise RuntimeError(f"unsupported ratio for ComfyUI: {self._input.ratio!r}") + return values + + async def _create_task(self) -> None: + from app.core.integrations.comfyui import apply_inputs + + prompt = apply_inputs(self._mapping, self._build_workflow_values()) + self._provider_task_id = await self._adapter.submit_prompt( + cfg=self._cfg, + prompt=prompt, + client_id=self._client_id, + timeout_s=self._timeout_s, + ) + + async def _poll_and_get_result(self) -> VideoGenerationResult: + from app.core.integrations.comfyui import ( + ComfyUIError, + extract_video_output, + read_execution_status, + ) + + prompt_id = self._provider_task_id or "" + if not prompt_id: + raise ComfyUIError("ComfyUI poll missing prompt id") + + deadline = time.monotonic() + self._timeout_s + while True: + entry = await self._adapter.get_history( + cfg=self._cfg, prompt_id=prompt_id, timeout_s=self._timeout_s + ) + if entry is not None: + status_val, message = read_execution_status(entry) + if status_val == "error": + raise ComfyUIError(f"ComfyUI execution failed: {message}") + if status_val == "success": + self._output = extract_video_output(entry, self._mapping.output_node) + break + if time.monotonic() >= deadline: + raise ComfyUIError( + f"ComfyUI render timed out after {self._timeout_s:.0f}s " + f"(prompt_id={prompt_id})" + ) + await self._sleep_poll() + + return VideoGenerationResult( + url=self._adapter.build_view_url(cfg=self._cfg, output=self._output or {}), + file_id=None, + provider_task_id=prompt_id, + provider="comfyui", + status="succeeded", + ) + + class VideoGenerationTask(BaseTask): - """按 provider 分派到 OpenAI / 火山实现;对外构造函数签名保持不变。""" + """按 provider 分派到 OpenAI / 火山 / ComfyUI 实现;对外构造函数签名保持不变。""" def __init__( self, @@ -258,6 +401,25 @@ def _build_volcengine_impl( timeout_s=timeout_s, ) + @staticmethod + def _build_comfyui_impl( + *, + provider_config: ProviderConfig, + input_: VideoGenerationInput, + poll_interval_s: float = 2.0, + timeout_s: float = 120.0, + ) -> AbstractVideoGenerationTask: + """从设置读取工作流映射路径;缺失时由构造函数明确报错。""" + from app.config import settings + + return ComfyUIVideoGenerationTask( + provider_config=provider_config, + input_=input_, + poll_interval_s=poll_interval_s, + timeout_s=timeout_s, + workflow_mapping_path=getattr(settings, "cas_comfyui_workflow_mapping", None), + ) + async def run(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any] | None: # type: ignore[override] return await self._impl.run(*args, **kwargs) diff --git a/backend/app/crypto_animal_studio/api/production.py b/backend/app/crypto_animal_studio/api/production.py index 3418a926..e488df40 100644 --- a/backend/app/crypto_animal_studio/api/production.py +++ b/backend/app/crypto_animal_studio/api/production.py @@ -10,6 +10,18 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings +from app.crypto_animal_studio.domain.import_ledger import CasImportLedger +from app.crypto_animal_studio.application.render_request import build_render_request +from app.crypto_animal_studio.application.render_tasks import ( + create_shot_render_task, + find_active_render_task, +) +from app.crypto_animal_studio.application.render_views import ( + build_artifact_view, + build_render_task_view, + latest_render_task, +) from app.crypto_animal_studio.production.enums import ArtifactType from app.crypto_animal_studio.production.models import CasProductionArtifact, CasProductionJob, CasProductionShot from app.crypto_animal_studio.production.orchestrator import ( @@ -24,6 +36,7 @@ ProductionArtifactView, ProductionJobView, ProductionShotView, + RenderTaskView, RetryProductionJobRequest, ) from app.dependencies import get_db @@ -42,7 +55,15 @@ async def _build_job_view(db: AsyncSession, job: CasProductionJob) -> Production artifacts = list((await db.execute(select(CasProductionArtifact).where(CasProductionArtifact.job_id == job.id))).scalars().all()) manifest = next((a for a in artifacts if a.artifact_type == ArtifactType.manifest.value), None) final = next((a for a in artifacts if a.artifact_type == ArtifactType.final_video.value), None) + # Step 7:最近一次单镜头渲染尝试(按镜头顺序取第一个有尝试的镜头,确定性)。 + render_task_view = None + for shot_row in shots: + task_row = await latest_render_task(db, production_shot_id=shot_row.id) + if task_row is not None: + render_task_view = build_render_task_view(task_row) + break return ProductionJobView( + render_task=render_task_view, id=job.id, project_id=job.project_id, episode_id=job.episode_id, @@ -80,6 +101,45 @@ async def create_production_job(body: CreateProductionJobRequest, db: AsyncSessi return success_response(data=await _build_job_view(db, job)) +@router.get("/jobs", response_model=ApiResponse[list[ProductionJobView]]) +async def list_production_jobs( + project_id: str, + episode_id: str | None = None, + chapter_id: str | None = None, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[list[ProductionJobView]]: + """按项目列出生产任务,可按剧集或章节过滤。 + + ``chapter_id`` 存在的原因:Jellyfish 的 Chapter **不建模剧集**,``ChapterRead`` + 没有 episode_id,因此前端只有路由里的 chapterId。权威的 章节→剧集 映射保存在 + ``cas_import_ledger(project_id, episode_id, chapter_id)``(导入时写入), + 这里在服务端解析它,避免前端猜测或把 chapter.id 当作 episode_id。 + + 章节没有导入记录时返回空列表(该章节不是由 CAS 导入的剧集)。 + """ + stmt = select(CasProductionJob).where(CasProductionJob.project_id == project_id) + + resolved_episode_id = episode_id + if resolved_episode_id is None and chapter_id: + ledger_stmt = select(CasImportLedger.episode_id).where( + CasImportLedger.project_id == project_id, + CasImportLedger.chapter_id == chapter_id, + ) + resolved_episode_id = (await db.execute(ledger_stmt)).scalars().first() + if resolved_episode_id is None: + return success_response(data=[]) + + if resolved_episode_id: + stmt = stmt.where(CasProductionJob.episode_id == resolved_episode_id) + # 全序排序:created_at 可能在同一秒内并列,单靠它不是确定性顺序。 + # cas_production_jobs 没有自增列(id 是随机 UUID),因此以 id 作次级键构成 + # **稳定的全序**;并列时的取舍是任意但可复现的。若日后需要「真正的最新」, + # 需要一个单调列(需迁移,超出 Step 7 范围)。 + stmt = stmt.order_by(CasProductionJob.created_at.desc(), CasProductionJob.id.desc()) + rows = list((await db.execute(stmt)).scalars().all()) + return success_response(data=[await _build_job_view(db, row) for row in rows]) + + @router.get("/jobs/{job_id}", response_model=ApiResponse[ProductionJobView]) async def get_production_job(job_id: str, db: AsyncSession = Depends(get_db)) -> ApiResponse[ProductionJobView]: """查询生产任务状态。""" @@ -106,22 +166,65 @@ async def list_production_artifacts(job_id: str, db: AsyncSession = Depends(get_ .scalars() .all() ) - return success_response( - data=[ - ProductionArtifactView( - id=a.id, - production_shot_id=a.production_shot_id, - artifact_type=a.artifact_type, - stage=a.stage, - provider=a.provider, - provider_model=a.provider_model, - file_path=a.file_path, - mime_type=a.mime_type, - checksum=a.checksum, - ) - for a in rows - ] + # Step 7:统一经 build_artifact_view 投影,补上 file_id / size / download_url 等可选字段。 + return success_response(data=[build_artifact_view(a) for a in rows]) + + +@router.post( + "/jobs/{job_id}/shots/{production_shot_id}/render", + response_model=ApiResponse[RenderTaskView], +) +async def start_shot_render( + job_id: str, + production_shot_id: str, + db: AsyncSession = Depends(get_db), +) -> ApiResponse[RenderTaskView]: + """为单个生产镜头发起一次真实渲染尝试(入队后立即返回)。 + + 既有 Step 6 端点无法表达「渲染某一个镜头」,故新增本路由。 + 实际执行走既有任务中心 + Celery;本路由只登记与入队。 + """ + job = await db.get(CasProductionJob, job_id) + if job is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"production job not found: {job_id}" + ) + shot = await db.get(CasProductionShot, production_shot_id) + if shot is None or shot.job_id != job_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"production shot not found in job: {production_shot_id}", + ) + + active = await find_active_render_task(db, production_shot_id=production_shot_id) + if active is not None: + # 已有进行中的尝试:幂等返回,不重复入队。 + return success_response(data=build_render_task_view(active)) + + # 提示词只在 application 层组装:这里只把镜头已持久化的字段作为上下文传入。 + render_request = build_render_request( + shot, + context={"scene": shot.image_prompt or "", "action": shot.video_prompt or ""}, + ratio="9:16", + negative_prompt=shot.negative_prompt or None, + ) + task_row, _attempt = await create_shot_render_task( + db, + job=job, + production_shot=shot, + render_request=render_request, + provider=settings.cas_render_provider, + base_url=settings.cas_comfyui_base_url, + poll_interval_s=settings.cas_render_poll_interval_s, + timeout_s=settings.cas_render_timeout_s, ) + # 任务行必须先可见,worker 才能按 id 取到它。 + await db.commit() + + from app.tasks.execute_task import enqueue_task_execution # 延迟导入,避免导入环 + + enqueue_task_execution(task_row.id) + return success_response(data=build_render_task_view(task_row)) @router.post("/jobs/{job_id}/retry", response_model=ApiResponse[ProductionJobView]) diff --git a/backend/app/crypto_animal_studio/application/render_request.py b/backend/app/crypto_animal_studio/application/render_request.py new file mode 100644 index 00000000..63be79bd --- /dev/null +++ b/backend/app/crypto_animal_studio/application/render_request.py @@ -0,0 +1,169 @@ +"""单镜头渲染请求的确定性构造(application 层)。 + +职责:把一个 ``CasProductionShot`` 及其 EP001 上下文,组装成**供应商中立**的 +``VideoGenerationInput``,并产出一份可复现快照。 + +纪律: +- 提示词只在这里拼装。API 路由与 React UI **不得**参与提示词构造; +- 相同输入必产出逐字节相同的输出(无时间戳、无随机数、无字典序抖动); +- 快照只保留可复现所需的最小信息,**不含**密钥,也**不含**整个 ComfyUI 工作流。 +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any + +from app.core.contracts.video_generation import VideoGenerationInput + +#: 提示词各段的固定顺序 —— 确定性的关键。 +_SECTION_ORDER: tuple[str, ...] = ( + "style", + "shot_type", + "camera_angle", + "camera_movement", + "scene", + "characters", + "action", + "beginning_state", + "ending_state", + "atmosphere", + "continuity", +) + +#: 快照 schema 版本,便于日后演进时区分历史记录。 +SNAPSHOT_VERSION = "step7.render-request.v1" + +#: 缺省负向提示词:抑制 Bible 中反复出现的生成风险(多角、文字、重复肢体)。 +DEFAULT_NEGATIVE_PROMPT = ( + "extra limbs, duplicated characters, extra horns, asymmetric horns, " + "readable text, watermark, subtitles, logo, deformed hands, " + "low quality, blurry, oversaturated neon wash" +) + + +def _clean(value: Any) -> str: + """规范化任意字段为单行紧凑文本;None/空白 → 空串。""" + if value is None: + return "" + text = str(value).strip() + return " ".join(text.split()) + + +@dataclass(frozen=True, slots=True) +class RenderRequest: + """一次单镜头渲染的完整请求。""" + + shot_id: str + production_shot_id: str + prompt: str + negative_prompt: str + ratio: str + seconds: int + seed: int | None + snapshot: dict[str, Any] = field(default_factory=dict) + + def to_video_input(self) -> VideoGenerationInput: + """转为共享的供应商中立契约。""" + return VideoGenerationInput( + prompt=self.prompt, + ratio=self.ratio, # type: ignore[arg-type] + seconds=self.seconds, + seed=self.seed, + ) + + +def _compose_sections(shot: Any, context: dict[str, Any]) -> dict[str, str]: + """收集提示词各段落(空段落后续会被丢弃)。""" + sections: dict[str, str] = { + "style": _clean(context.get("visual_style")), + "shot_type": _clean(context.get("shot_type")), + "camera_angle": _clean(context.get("camera_angle")), + "camera_movement": _clean(context.get("camera_movement")), + "scene": _clean(context.get("scene")), + "characters": _clean(context.get("characters")), + "action": _clean(getattr(shot, "video_prompt", "") or context.get("action")), + "beginning_state": _clean(context.get("beginning_state")), + "ending_state": _clean(context.get("ending_state")), + "atmosphere": _clean(context.get("atmosphere")), + "continuity": _clean(context.get("continuity_notes")), + } + return sections + + +def build_render_request( + shot: Any, + *, + context: dict[str, Any] | None = None, + ratio: str = "9:16", + seed: int | None = None, + negative_prompt: str | None = None, +) -> RenderRequest: + """由生产镜头与上下文构造确定性渲染请求。 + + 参数: + shot: ``CasProductionShot``(或具备同名属性的对象)。 + context: EP001 侧的补充信息(角色、场景、相机、创意方向、输出规格等)。 + ratio: 输出宽高比;EP001 为 9:16。 + seed: 可选随机种子;提供后渲染可复现。 + negative_prompt: 覆盖缺省负向提示词。 + 返回: + RenderRequest(含可复现快照)。 + 异常: + ValueError:镜头没有任何可用于成像的描述。 + """ + ctx = dict(context or {}) + sections = _compose_sections(shot, ctx) + + ordered = [(key, sections[key]) for key in _SECTION_ORDER if sections.get(key)] + if not any(key in {"action", "scene", "characters"} for key, _ in ordered): + raise ValueError( + f"shot {getattr(shot, 'source_shot_id', '?')!r} has no action, scene or character " + "description to render from" + ) + prompt = ". ".join(text.rstrip(".") for _key, text in ordered) + "." + + seconds_raw = getattr(shot, "duration_seconds", None) or ctx.get("duration_seconds") or 0 + seconds = max(1, int(round(float(seconds_raw)))) if seconds_raw else 1 + + negative = _clean(negative_prompt if negative_prompt is not None else DEFAULT_NEGATIVE_PROMPT) + + snapshot: dict[str, Any] = { + "snapshot_version": SNAPSHOT_VERSION, + "source_shot_id": _clean(getattr(shot, "source_shot_id", "")), + "sequence": getattr(shot, "sequence", None), + "ratio": ratio, + "seconds": seconds, + "seed": seed, + "sections": dict(ordered), + "negative_prompt": negative, + "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), + } + + return RenderRequest( + shot_id=_clean(getattr(shot, "source_shot_id", "")), + production_shot_id=_clean(getattr(shot, "id", "")), + prompt=prompt, + negative_prompt=negative, + ratio=ratio, + seconds=seconds, + seed=seed, + snapshot=snapshot, + ) + + +def snapshot_fingerprint(snapshot: dict[str, Any]) -> str: + """快照的稳定指纹:用于判定重试是否使用了相同请求。""" + canonical = json.dumps(snapshot, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +__all__ = [ + "DEFAULT_NEGATIVE_PROMPT", + "RenderRequest", + "SNAPSHOT_VERSION", + "build_render_request", + "snapshot_fingerprint", +] diff --git a/backend/app/crypto_animal_studio/application/render_tasks.py b/backend/app/crypto_animal_studio/application/render_tasks.py new file mode 100644 index 00000000..60937b42 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/render_tasks.py @@ -0,0 +1,403 @@ +"""CAS 单镜头真实渲染:Task Center 任务的创建与执行(application 层)。 + +复用而非新建: +- 队列/入口:既有 Task Center + ``enqueue_task_execution`` + Celery ``task.execute``; +- 执行器基类:既有 ``AbstractAsyncDelegatingExecutor``(在 task_registry 注册); +- 供应商分派:既有 ``VideoGenerationTask`` → ``resolve_task_adapter``; +- 存储:既有 ``create_file_from_url_or_b64``(对象存储 + FileItem); +- 关联:既有 ``GenerationTaskLink``(承载 task↔生产镜头 的关系)。 + +本模块只补上 CAS 侧缺失的一段:把供应商产物登记为 ``CasProductionArtifact``, +并与 job / 生产镜头 / 剧集范围关联。**不修改** ``run_video_generation_task``, +因此既有影视线的行为完全不变。 +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import datetime, timezone +from typing import Any + +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.crypto_animal_studio.production.enums import ArtifactType, JobStatus, Stage +from app.crypto_animal_studio.production.models import ( + CasProductionArtifact, + CasProductionJob, + CasProductionShot, +) +from app.models.task import GenerationTask, GenerationTaskStatus +from app.models.task_links import GenerationTaskLink +from app.services.common import create_and_refresh + +logger = logging.getLogger(__name__) + +#: 任务种类:与既有 video_generation 共用注册表与队列,但走 CAS 的产物落库。 +CAS_RENDER_SHOT_TASK_KIND = "cas_render_shot" + +#: 业务关联类型(``relation_type`` 为 String(32))。 +CAS_SHOT_RENDER_RELATION_TYPE = "cas_shot_render" + +#: 视为「仍在进行」的任务状态。 +_ACTIVE_TASK_STATUSES = ( + GenerationTaskStatus.pending, + GenerationTaskStatus.running, + GenerationTaskStatus.streaming, +) + +#: 面向用户的安全失败文案:绝不回显供应商响应体、地址或凭据。 +_SAFE_FAILURE_MESSAGES: dict[str, str] = { + "config": "Render provider is not configured correctly. Check the CAS render settings.", + "timeout": "The render provider did not finish before the configured timeout.", + "provider": "The render provider reported an execution failure.", + "network": "The render provider could not be reached.", + "output": "The render finished but produced no usable video output.", + "unknown": "Rendering failed. See server logs for details.", +} + + +class RenderTaskError(Exception): + """CAS 渲染任务的领域错误。""" + + +def _utcnow() -> datetime: + """UTC 当前时间(naive,与既有模型一致)。""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def classify_failure(exc: BaseException) -> tuple[str, str]: + """把异常映射为 ``(error_code, 安全的用户可见文案)``。 + + 只依据异常类型与少量关键词判断,**不把异常文本原样返回给用户**, + 以免泄露供应商响应体、base_url 或凭据。 + """ + name = type(exc).__name__ + text = str(exc).lower() + if name == "WorkflowConfigError" or "not configured" in text: + code = "config" + elif "timed out" in text or name in {"TimeoutError", "ReadTimeout", "ConnectTimeout"}: + code = "timeout" + elif "no video output" in text or "no download url" in text: + code = "output" + elif name in {"ConnectError", "HTTPError", "RequestError"} or "could not be reached" in text: + code = "network" + elif name == "ComfyUIError" or "execution failed" in text or "rejected the workflow" in text: + # worker 会把供应商错误包装成 RenderTaskError,因此必须同时按消息判别, + # 否则真实执行路径上的供应商失败会被误判为 unknown(由 E2E 测试发现)。 + code = "provider" + else: + code = "unknown" + return code, _SAFE_FAILURE_MESSAGES[code] + + +async def find_active_render_task( + db: AsyncSession, *, production_shot_id: str +) -> GenerationTask | None: + """该生产镜头是否已有进行中的渲染任务(用于禁用重复提交)。""" + stmt = ( + select(GenerationTask) + .join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id) + .where( + GenerationTaskLink.relation_type == CAS_SHOT_RENDER_RELATION_TYPE, + GenerationTaskLink.relation_entity_id == production_shot_id, + GenerationTask.status.in_(_ACTIVE_TASK_STATUSES), + ) + .limit(1) + ) + return (await db.execute(stmt)).scalars().first() + + +async def count_render_attempts(db: AsyncSession, *, production_shot_id: str) -> int: + """该生产镜头累计的渲染尝试次数(重试的可追溯性)。""" + stmt = select(GenerationTaskLink).where( + GenerationTaskLink.relation_type == CAS_SHOT_RENDER_RELATION_TYPE, + GenerationTaskLink.relation_entity_id == production_shot_id, + ) + return len((await db.execute(stmt)).scalars().all()) + + +async def create_shot_render_task( + db: AsyncSession, + *, + job: CasProductionJob, + production_shot: CasProductionShot, + render_request: Any, + provider: str, + base_url: str | None, + api_key: str = "", + poll_interval_s: float = 3.0, + timeout_s: float = 1800.0, +) -> tuple[GenerationTask, int]: + """登记一次渲染尝试(不入队;入队由 API 层在提交后触发)。 + + 返回 ``(task_row, attempt)``。调用方拥有事务:本函数只 flush。 + """ + attempt = await count_render_attempts(db, production_shot_id=production_shot.id) + 1 + + store = SqlAlchemyTaskStore(db) + manager = TaskManager(store=store, strategies={}) + record = await manager.create( + task=_CreateOnlyTask(), + mode=DeliveryMode.async_polling, + task_kind=CAS_RENDER_SHOT_TASK_KIND, + run_args={ + "job_id": job.id, + "production_shot_id": production_shot.id, + "project_id": job.project_id, + "episode_id": job.episode_id, + "attempt": attempt, + "provider": provider, + "base_url": base_url, + "api_key": api_key, # 自托管 ComfyUI 通常为空串 + "poll_interval_s": poll_interval_s, + "timeout_s": timeout_s, + # 供应商中立的输入 + 可复现快照(不含工作流负载与密钥) + "input": render_request.to_video_input().model_dump(), + "request_snapshot": render_request.snapshot, + }, + ) + db.add( + GenerationTaskLink( + task_id=record.id, + resource_type="video", + relation_type=CAS_SHOT_RENDER_RELATION_TYPE, + relation_entity_id=production_shot.id, + ) + ) + await db.flush() + task_row = await db.get(GenerationTask, record.id) + if task_row is None: # pragma: no cover - 刚创建必然存在 + raise RenderTaskError("failed to create render task record") + return task_row, attempt + + +class _CreateOnlyTask: + """仅用于 ``TaskManager.create``;实际执行由 worker 驱动。""" + + async def run(self, *args: object, **kwargs: object) -> None: + """占位。""" + return None + + async def status(self) -> dict[str, object]: + """占位。""" + return {} + + async def is_done(self) -> bool: + """占位。""" + return False + + async def get_result(self) -> object: + """占位。""" + return None + + +async def _existing_video_artifact( + db: AsyncSession, *, job_id: str, production_shot_id: str +) -> CasProductionArtifact | None: + """查询该镜头是否已有视频产物(幂等与「保留既有成功产物」的依据)。""" + stmt = ( + select(CasProductionArtifact) + .where( + CasProductionArtifact.job_id == job_id, + CasProductionArtifact.production_shot_id == production_shot_id, + CasProductionArtifact.artifact_type == ArtifactType.video.value, + ) + .limit(1) + ) + return (await db.execute(stmt)).scalars().first() + + +async def persist_render_artifact( + db: AsyncSession, + *, + job_id: str, + production_shot_id: str, + file_item: Any, + provider: str, + provider_job_id: str, + attempt: int, + request_snapshot: dict[str, Any], + mime_type: str = "video/mp4", + size_bytes: int | None = None, +) -> tuple[CasProductionArtifact, bool]: + """登记 CasProductionArtifact,并保证幂等。 + + 返回 ``(artifact, created)``。已存在成功产物时**不覆盖**,直接返回既有记录, + 因此重复投递与重试都不会产生第二条成功产物。 + """ + existing = await _existing_video_artifact( + db, job_id=job_id, production_shot_id=production_shot_id + ) + if existing is not None: + return existing, False + + artifact = CasProductionArtifact( + id=str(uuid.uuid4()), + job_id=job_id, + production_shot_id=production_shot_id, + artifact_type=ArtifactType.video.value, + stage=Stage.video_generation.value, + provider=provider, + provider_model="", + file_path=getattr(file_item, "storage_key", "") or "", + mime_type=mime_type, + checksum="", # 对象存储产物:本地 checksum 不适用,改由 FileItem 承载 + metadata_json={ + "file_id": getattr(file_item, "id", ""), + "provider_job_id": provider_job_id, + "attempt": attempt, + "size_bytes": size_bytes, + "request_snapshot": request_snapshot, + "completed_at": _utcnow().isoformat(), + }, + ) + return await create_and_refresh(db, artifact), True + + +async def run_cas_shot_render_task(task_id: str, run_args: dict | None = None) -> None: + """执行一次 CAS 单镜头渲染。 + + 签名与既有 worker runner 一致 ``(task_id, run_args)``,可直接注册到 + ``AbstractAsyncDelegatingExecutor``。任何异常都必须落到**终态 failed**, + 而不是让任务永远停在 running。 + """ + # 局部导入:避免 application 层在模块导入期拉起 film/服务层依赖。 + from app.core.contracts.provider import ProviderConfig + from app.core.contracts.video_generation import VideoGenerationInput + from app.core.tasks.video_generation_tasks import VideoGenerationTask + from app.services.worker.async_task_support import cancel_if_requested_async + from app.utils.files import create_file_from_url_or_b64 + + async with async_session_maker() as db: + store = SqlAlchemyTaskStore(db) + task = await store.get(task_id) + if task is None: + logger.warning("cas render task not found: %s", task_id) + return + if not run_args: + run_args = task.payload.get("run_args") or {} + await store.set_status(task_id, TaskStatus.running) + await store.set_progress(task_id, 5) + await db.commit() + + provider = str(run_args.get("provider") or "") + job_id = str(run_args.get("job_id") or "") + production_shot_id = str(run_args.get("production_shot_id") or "") + attempt = int(run_args.get("attempt") or 1) + snapshot = dict(run_args.get("request_snapshot") or {}) + + try: + async with async_session_maker() as db: + store = SqlAlchemyTaskStore(db) + + # 幂等:已有成功产物则直接复用,不重复调用供应商。 + existing = await _existing_video_artifact( + db, job_id=job_id, production_shot_id=production_shot_id + ) + if existing is not None: + await store.set_result( + task_id, + {"artifact_id": existing.id, "reused": True, "attempt": attempt}, + ) + await store.set_progress(task_id, 100) + await store.set_status(task_id, TaskStatus.succeeded) + await db.commit() + return + + if await cancel_if_requested_async(store=store, task_id=task_id, session=db): + return + + await store.set_progress(task_id, 20) # submitting + await db.commit() + + video_task = VideoGenerationTask( + provider_config=ProviderConfig( + provider=provider, # type: ignore[arg-type] + api_key=str(run_args.get("api_key") or ""), + base_url=run_args.get("base_url"), + ), + input_=VideoGenerationInput.model_validate(dict(run_args.get("input") or {})), + poll_interval_s=float(run_args.get("poll_interval_s") or 3.0), + timeout_s=float(run_args.get("timeout_s") or 1800.0), + ) + await video_task.run() + result = await video_task.get_result() + if result is None: + status = await video_task.status() + raise RenderTaskError(str(status.get("error") or "provider returned no result")) + + if await cancel_if_requested_async(store=store, task_id=task_id, session=db): + return + + await store.set_progress(task_id, 80) # downloading + await db.commit() + + file_item = await create_file_from_url_or_b64( + db, + url=result.url, + name=f"cas-shot-{production_shot_id}-attempt{attempt}", + prefix=f"cas/renders/{job_id}/{production_shot_id}", + httpx_timeout=600.0, + ) + + artifact, _created = await persist_render_artifact( + db, + job_id=job_id, + production_shot_id=production_shot_id, + file_item=file_item, + provider=provider, + provider_job_id=str(result.provider_task_id or ""), + attempt=attempt, + request_snapshot=snapshot, + ) + + shot = await db.get(CasProductionShot, production_shot_id) + if shot is not None: + shot.status = JobStatus.completed.value + shot.current_stage = Stage.video_generation.value + shot.error_message = "" + + await store.set_result( + task_id, + { + "artifact_id": artifact.id, + "file_id": getattr(file_item, "id", ""), + "provider_job_id": str(result.provider_task_id or ""), + "attempt": attempt, + "reused": False, + }, + ) + await store.set_progress(task_id, 100) + await store.set_status(task_id, TaskStatus.succeeded) + await db.commit() + except Exception as exc: # noqa: BLE001 # 任何失败都必须落终态 + code, safe_message = classify_failure(exc) + # 完整异常只进日志,不进 API 响应。 + logger.exception("cas render task failed: task=%s code=%s", task_id, code) + async with async_session_maker() as db: + store = SqlAlchemyTaskStore(db) + await store.set_error(task_id, f"{code}: {safe_message}") + await store.set_status(task_id, TaskStatus.failed) + shot = await db.get(CasProductionShot, production_shot_id) + if shot is not None: + shot.status = JobStatus.failed.value + shot.error_message = safe_message + await db.commit() + + +__all__ = [ + "CAS_RENDER_SHOT_TASK_KIND", + "CAS_SHOT_RENDER_RELATION_TYPE", + "RenderTaskError", + "classify_failure", + "count_render_attempts", + "create_shot_render_task", + "find_active_render_task", + "persist_render_artifact", + "run_cas_shot_render_task", +] diff --git a/backend/app/crypto_animal_studio/application/render_views.py b/backend/app/crypto_animal_studio/application/render_views.py new file mode 100644 index 00000000..50fa91e0 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/render_views.py @@ -0,0 +1,133 @@ +"""把渲染尝试与产物投影为 API 视图(只读,不写库)。 + +设计要点: +- **不新增数据库列**:任务状态/进度/错误全部来自任务中心的 ``generation_tasks``, + 通过 ``GenerationTaskLink(relation_type="cas_shot_render")`` 关联到生产镜头; +- **确定性选取**:同一镜头可能有多次重试链接,按 ``created_at DESC, id DESC`` + 取最近一次,保证刷新页面得到稳定结果; +- **安全**:只回传已在写入阶段脱敏过的错误文案,绝不回传堆栈、凭据或供应商响应体。 +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.application.render_tasks import CAS_SHOT_RENDER_RELATION_TYPE +from app.crypto_animal_studio.schemas.production import ProductionArtifactView, RenderTaskView +from app.models.task import GenerationTask +from app.models.task_links import GenerationTaskLink + +#: 终态集合:前端据此停止轮询。 +TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"succeeded", "failed", "cancelled"}) + +#: 进度 → 阶段文案。与 run_cas_shot_render_task 的进度阶梯一致。 +_STAGE_MESSAGES: tuple[tuple[int, str], ...] = ( + (100, "Completed"), + (80, "Downloading generated video"), + (20, "Submitted to render provider"), + (5, "Worker started"), + (0, "Queued"), +) + + +def _status_value(row: Any) -> str: + """把 ORM 枚举/字符串统一为字符串。""" + status = getattr(row, "status", "") + return status.value if hasattr(status, "value") else str(status or "") + + +def stage_message_for(status: str, progress: int | None) -> str: + """由状态与进度推导安全的阶段文案。""" + if status == "failed": + return "Failed" + if status == "cancelled": + return "Cancelled" + if status in {"pending", ""}: + return "Queued" + for threshold, message in _STAGE_MESSAGES: + if (progress or 0) >= threshold: + return message + return "Queued" + + +async def latest_render_task( + db: AsyncSession, *, production_shot_id: str +) -> GenerationTask | None: + """取该生产镜头最近一次渲染尝试。 + + 排序依据是 ``GenerationTaskLink.id``(自增整数)而**不是** + ``GenerationTask.created_at`` + UUID:同一秒内创建的两次尝试时间戳会相同, + 而 ``GenerationTask.id`` 是随机 UUID,用它做次级排序会稳定但**错误**地选中 + 较早的尝试(由端到端重试测试发现)。自增链接 ID 单调反映插入顺序, + 因此既确定又正确。 + """ + stmt = ( + select(GenerationTask) + .join(GenerationTaskLink, GenerationTaskLink.task_id == GenerationTask.id) + .where( + GenerationTaskLink.relation_type == CAS_SHOT_RENDER_RELATION_TYPE, + GenerationTaskLink.relation_entity_id == production_shot_id, + ) + .order_by(GenerationTaskLink.id.desc()) + .limit(1) + ) + return (await db.execute(stmt)).scalars().first() + + +def build_render_task_view(task: GenerationTask | None) -> RenderTaskView | None: + """把任务行投影为视图;无尝试时返回 None。""" + if task is None: + return None + status = _status_value(task) + progress = getattr(task, "progress", None) + result = getattr(task, "result", None) or {} + error = (getattr(task, "error", "") or "").strip() + return RenderTaskView( + task_id=task.id, + status=status, + progress=progress if isinstance(progress, int) else None, + stage_message=stage_message_for(status, progress if isinstance(progress, int) else None), + provider_task_id=str(result.get("provider_job_id") or "") or None, + error_reason=error or None, + attempt=result.get("attempt") if isinstance(result.get("attempt"), int) else None, + is_terminal=status in TERMINAL_TASK_STATUSES, + ) + + +def build_artifact_view(artifact: Any) -> ProductionArtifactView: + """把产物行投影为视图,并补上前端播放所需的安全字段。 + + ``download_url`` 复用既有受控端点 ``/api/v1/studio/files/{file_id}/download``, + 不新开公开静态路由,也不回传对象存储的私有绝对路径。 + """ + metadata = getattr(artifact, "metadata_json", None) or {} + file_id = str(metadata.get("file_id") or "") or None + size = metadata.get("size_bytes") + return ProductionArtifactView( + id=artifact.id, + production_shot_id=artifact.production_shot_id, + artifact_type=artifact.artifact_type, + stage=artifact.stage, + provider=artifact.provider, + provider_model=artifact.provider_model, + file_path=artifact.file_path, + mime_type=artifact.mime_type, + checksum=artifact.checksum, + file_id=file_id, + size_bytes=size if isinstance(size, int) else None, + download_url=f"/api/v1/studio/files/{file_id}/download" if file_id else None, + provider_job_id=str(metadata.get("provider_job_id") or "") or None, + attempt=metadata.get("attempt") if isinstance(metadata.get("attempt"), int) else None, + ) + + +__all__ = [ + "TERMINAL_TASK_STATUSES", + "build_artifact_view", + "build_render_task_view", + "latest_render_task", + "stage_message_for", +] diff --git a/backend/app/crypto_animal_studio/application/shot_render.py b/backend/app/crypto_animal_studio/application/shot_render.py new file mode 100644 index 00000000..65562477 --- /dev/null +++ b/backend/app/crypto_animal_studio/application/shot_render.py @@ -0,0 +1,287 @@ +"""单镜头视频渲染的编排(application 层)。 + +把一个 ``CasProductionShot`` 接到 Jellyfish **既有**的视频生成通道上: + + CasProductionShot → 确定性渲染请求 → TaskManager(task_kind="video_generation") + → enqueue_task_execution → Celery task.execute → run_video_generation_task + → resolve_task_adapter("video_generation", provider) → FileItem + → CasProductionArtifact + +刻意不新增队列、执行器、Celery 入口或 CAS 本地供应商注册表。 + +事务纪律:本模块只 ``flush``(经 ``create_and_refresh``),从不自行 commit; +调用方(请求会话或 worker 会话)拥有事务。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto_animal_studio.application.render_request import ( + RenderRequest, + build_render_request, + snapshot_fingerprint, +) +from app.crypto_animal_studio.domain.import_ledger import CasImportLedger +from app.crypto_animal_studio.production.enums import ArtifactType, Stage +from app.crypto_animal_studio.production.models import ( + CasProductionArtifact, + CasProductionJob, + CasProductionShot, +) +from app.models.studio import Shot +from app.models.task_links import GenerationTaskLink +from app.services.common import create_and_refresh + +#: 与既有影片任务一致的 task_kind —— 复用同一个执行器。 +VIDEO_TASK_KIND = "video_generation" + +#: CAS 渲染的业务关联类型(``relation_type`` 为 String(32))。 +CAS_RENDER_RELATION_TYPE = "cas_shot_render" + + +class ShotRenderError(Exception): + """单镜头渲染编排失败(供 API 层翻译为 HTTP)。""" + + +class JellyfishShotNotFoundError(ShotRenderError): + """找不到与 CAS 生产镜头对应的 Jellyfish Shot。""" + + +@dataclass(slots=True) +class RenderStartResult: + """一次渲染启动的结果。""" + + task_id: str + job_id: str + production_shot_id: str + jellyfish_shot_id: str + provider: str + snapshot_fingerprint: str + attempt: int + + +async def resolve_jellyfish_shot( + db: AsyncSession, *, job: CasProductionJob, production_shot: CasProductionShot +) -> Shot: + """按「导入台账 → 章节 → 镜头序号」解析出 Jellyfish Shot。 + + CAS 的 ``source_shot_id``(如 ``SC01``)与 Jellyfish 的 Shot UUID 属于不同键空间, + 且 ``CasProductionShot`` 未持久化二者的关联,因此在渲染时按章节 + 序号解析。 + 未导入过该剧集时明确失败,而不是静默跳过持久化。 + """ + ledger = ( + await db.execute( + select(CasImportLedger).where( + CasImportLedger.project_id == job.project_id, + CasImportLedger.episode_id == job.episode_id, + ) + ) + ).scalars().first() + if ledger is None or not ledger.chapter_id: + raise JellyfishShotNotFoundError( + f"episode {job.episode_id!r} has not been imported into project " + f"{job.project_id!r}; import it before rendering" + ) + + shot = ( + await db.execute( + select(Shot) + .where(Shot.chapter_id == ledger.chapter_id, Shot.index == production_shot.sequence) + .limit(1) + ) + ).scalars().first() + if shot is None: + raise JellyfishShotNotFoundError( + f"no Jellyfish shot at index {production_shot.sequence} in chapter " + f"{ledger.chapter_id!r} for episode {job.episode_id!r}" + ) + return shot + + +def build_shot_context(production_shot: CasProductionShot) -> dict[str, Any]: + """从生产镜头收集可用于提示词的字段(缺失字段自然被跳过)。""" + return { + "action": production_shot.video_prompt or production_shot.image_prompt, + "duration_seconds": production_shot.duration_seconds, + } + + +async def count_attempts(db: AsyncSession, *, job_id: str, production_shot_id: str) -> int: + """已存在的渲染产物数(用于给重试编号,保证可追溯)。""" + rows = ( + await db.execute( + select(CasProductionArtifact).where( + CasProductionArtifact.job_id == job_id, + CasProductionArtifact.production_shot_id == production_shot_id, + CasProductionArtifact.artifact_type == ArtifactType.video.value, + ) + ) + ).scalars().all() + return len(rows) + + +def resolve_provider_config(settings: Any) -> tuple[str, str, str | None]: + """按配置解析 ``(provider, api_key, base_url)``。 + + ComfyUI 为自托管、无 API key;未配置地址时明确失败,绝不猜测机器地址, + 也绝不退化到假供应商。 + """ + provider = (getattr(settings, "cas_render_provider", "") or "").strip().lower() + if not provider: + raise ShotRenderError("CAS_RENDER_PROVIDER is not configured") + if provider == "comfyui": + base_url = (getattr(settings, "cas_comfyui_base_url", None) or "").strip() + if not base_url: + raise ShotRenderError( + "CAS_COMFYUI_BASE_URL is not configured; set it before starting a render" + ) + return provider, "", base_url + # 其他供应商沿用 Jellyfish 既有的 Provider/Model 配置通道。 + raise ShotRenderError( + f"provider {provider!r} must be configured through the existing Jellyfish " + "provider/model settings; CAS only self-configures 'comfyui'" + ) + + +def build_run_args( + *, + request: RenderRequest, + jellyfish_shot_id: str, + provider: str, + api_key: str, + base_url: str | None, + job_id: str, + attempt: int, +) -> dict[str, Any]: + """构造既有执行器所需的 run_args。 + + 只放入可复现所需的最小信息:不含整个 ComfyUI 工作流,不含任何密钥以外的凭据字段 + (api_key 由既有执行器契约要求,ComfyUI 下为空串)。 + """ + return { + "provider": provider, + "api_key": api_key, + "base_url": base_url, + "input": request.to_video_input().model_dump(exclude_none=True), + "shot_id": jellyfish_shot_id, + # --- CAS 侧关联信息:供执行器完成后登记生产产物 --- + "cas_job_id": job_id, + "cas_production_shot_id": request.production_shot_id, + "cas_source_shot_id": request.shot_id, + "cas_attempt": attempt, + "cas_snapshot": request.snapshot, + "cas_snapshot_fingerprint": snapshot_fingerprint(request.snapshot), + } + + +async def attach_render_artifact( + db: AsyncSession, + *, + job_id: str, + production_shot_id: str, + file_id: str, + storage_key: str, + mime_type: str, + provider: str, + provider_task_id: str, + snapshot: dict[str, Any] | None = None, + attempt: int = 1, + size_bytes: int | None = None, +) -> CasProductionArtifact: + """幂等登记一条视频产物。 + + 幂等键为 ``(job_id, production_shot_id, artifact_type=video, metadata.file_id)``: + 同一个 FileItem 重复投递只更新既有行,不会新增;不同 FileItem(即新的重试尝试) + 会新增一行,从而**保留**此前成功的产物。 + + 说明:这里不使用 ``ArtifactManager.register``——它按本地文件计算校验和,而渲染 + 产物存放在对象存储中,本地并无该文件。 + """ + existing_rows = ( + await db.execute( + select(CasProductionArtifact).where( + CasProductionArtifact.job_id == job_id, + CasProductionArtifact.production_shot_id == production_shot_id, + CasProductionArtifact.artifact_type == ArtifactType.video.value, + ) + ) + ).scalars().all() + + metadata: dict[str, Any] = { + "file_id": file_id, + "provider_task_id": provider_task_id, + "attempt": attempt, + } + if size_bytes is not None: + metadata["size_bytes"] = size_bytes + if snapshot: + metadata["request_snapshot"] = snapshot + + for row in existing_rows: + if (row.metadata_json or {}).get("file_id") == file_id: + # 重复投递:就地更新,不新增,也不破坏其它尝试的产物。 + row.stage = Stage.video_generation.value + row.provider = provider + row.file_path = storage_key + row.mime_type = mime_type + row.metadata_json = metadata + await db.flush() + return row + + artifact = CasProductionArtifact( + id=str(uuid.uuid4()), + job_id=job_id, + production_shot_id=production_shot_id, + artifact_type=ArtifactType.video.value, + stage=Stage.video_generation.value, + provider=provider, + provider_model="", + file_path=storage_key, + mime_type=mime_type, + checksum="", # 对象存储产物:本地无文件可计算校验和 + metadata_json=metadata, + ) + return await create_and_refresh(db, artifact) + + +async def link_task_to_shot(db: AsyncSession, *, task_id: str, production_shot_id: str) -> None: + """用既有的 GenerationTaskLink 记录任务与生产镜头的关联。""" + db.add( + GenerationTaskLink( + task_id=task_id, + resource_type="video", + relation_type=CAS_RENDER_RELATION_TYPE, + relation_entity_id=production_shot_id, + ) + ) + await db.flush() + + +def build_request_for_shot(production_shot: CasProductionShot, *, ratio: str = "9:16", seed: int | None = None) -> RenderRequest: + """由生产镜头构造确定性渲染请求(提示词只在 render_request 层拼装)。""" + return build_render_request( + production_shot, context=build_shot_context(production_shot), ratio=ratio, seed=seed + ) + + +__all__ = [ + "CAS_RENDER_RELATION_TYPE", + "JellyfishShotNotFoundError", + "RenderStartResult", + "ShotRenderError", + "VIDEO_TASK_KIND", + "attach_render_artifact", + "build_request_for_shot", + "build_run_args", + "build_shot_context", + "count_attempts", + "link_task_to_shot", + "resolve_jellyfish_shot", + "resolve_provider_config", +] diff --git a/backend/app/crypto_animal_studio/schemas/production.py b/backend/app/crypto_animal_studio/schemas/production.py index a4c62170..554d6ed9 100644 --- a/backend/app/crypto_animal_studio/schemas/production.py +++ b/backend/app/crypto_animal_studio/schemas/production.py @@ -23,7 +23,13 @@ class CreateProductionJobRequest(BaseModel): union_mode=EPISODE_PACKAGE_UNION_MODE, description="待生产的 EpisodePackage(严格校验;接受 schema_version 1.0 或 1.1)", ) - mode: Literal["mock"] = Field("mock", description="供应商模式;本冲刺仅支持 mock") + mode: Literal["mock", "render"] = Field( + "mock", + description=( + "供应商模式:mock=Step 6 的确定性模拟流水线(行为完全不变);" + "render=Step 7 单镜头真实渲染,需显式选择,绝不由 mock 隐式转真" + ), + ) class RetryProductionJobRequest(BaseModel): @@ -36,7 +42,7 @@ class RetryProductionJobRequest(BaseModel): union_mode=EPISODE_PACKAGE_UNION_MODE, description="与原任务一致的 EpisodePackage(用于重跑;接受 schema_version 1.0 或 1.1)", ) - mode: Literal["mock"] = Field("mock", description="供应商模式;本冲刺仅支持 mock") + mode: Literal["mock", "render"] = Field("mock", description="供应商模式;用于重跑") class ProductionShotView(BaseModel): @@ -54,7 +60,11 @@ class ProductionShotView(BaseModel): class ProductionArtifactView(BaseModel): - """产物视图。""" + """产物视图。 + + Step 7 追加的字段全部可选,因此 Step 6 的响应形状依然合法。 + ``download_url`` 复用既有的受控文件端点,不新开公开静态路由。 + """ model_config = ConfigDict(extra="forbid") @@ -68,6 +78,31 @@ class ProductionArtifactView(BaseModel): mime_type: str checksum: str + # --- Step 7 追加(可选) --- + file_id: str | None = Field(None, description="对应的 Jellyfish FileItem.id(对象存储产物)") + size_bytes: int | None = Field(None, description="字节数;仅在存储层能提供时才有值") + download_url: str | None = Field( + None, + description="播放/下载地址,复用既有 /api/v1/studio/files/{file_id}/download 受控端点", + ) + provider_job_id: str | None = Field(None, description="供应商侧任务/prompt ID(可安全展示)") + attempt: int | None = Field(None, description="产生该产物的尝试序号") + + +class RenderTaskView(BaseModel): + """当前/最近一次渲染尝试的任务视图(由任务中心派生,不新增数据库列)。""" + + model_config = ConfigDict(extra="forbid") + + task_id: str + status: str = Field(..., description="pending/running/streaming/succeeded/failed/cancelled") + progress: int | None = Field(None, description="0-100;供应商不暴露进度时为 null") + stage_message: str | None = Field(None, description="安全的阶段文案") + provider_task_id: str | None = Field(None, description="供应商任务 ID(成功后可得)") + error_reason: str | None = Field(None, description="安全的失败原因;绝不含堆栈或凭据") + attempt: int | None = Field(None, description="尝试序号") + is_terminal: bool = Field(..., description="是否已到终态(前端据此停止轮询)") + class ProductionJobView(BaseModel): """生产任务视图。""" @@ -89,6 +124,11 @@ class ProductionJobView(BaseModel): manifest_path: str | None = None final_output: str | None = None + # --- Step 7 追加(可选):最近一次渲染尝试,按 created_at desc, id desc 确定性选取 --- + render_task: RenderTaskView | None = Field( + None, description="该任务下最近一次 cas_shot_render 尝试;无渲染尝试时为 null" + ) + __all__ = [ "CreateProductionJobRequest", @@ -96,4 +136,5 @@ class ProductionJobView(BaseModel): "ProductionJobView", "ProductionShotView", "ProductionArtifactView", + "RenderTaskView", ] diff --git a/backend/app/services/worker/task_registry.py b/backend/app/services/worker/task_registry.py index 525f40b0..0b936a24 100644 --- a/backend/app/services/worker/task_registry.py +++ b/backend/app/services/worker/task_registry.py @@ -12,6 +12,10 @@ CAS_IMPORT_EPISODE_TASK_KIND, run_cas_import_task, ) +from app.crypto_animal_studio.application.render_tasks import ( + CAS_RENDER_SHOT_TASK_KIND, + run_cas_shot_render_task, +) from app.services.film.generated_video import run_video_generation_task from app.services.film.shot_frame_prompt_tasks import run_shot_frame_prompt_task from app.services.script_processing_worker import ( @@ -87,3 +91,12 @@ def resolve(self, task_kind: str) -> AbstractWorkerTaskExecutor: timeout_seconds=300.0, ), ) +# CAS:单镜头真实渲染。复用同一注册表与队列;视频生成耗时长,超时给足余量。 +task_executor_registry.register( + CAS_RENDER_SHOT_TASK_KIND, + AbstractAsyncDelegatingExecutor( + task_kind=CAS_RENDER_SHOT_TASK_KIND, + runner=run_cas_shot_render_task, + timeout_seconds=3600.0, + ), +) diff --git a/backend/tests/fixtures/comfyui/example_mapping.json b/backend/tests/fixtures/comfyui/example_mapping.json new file mode 100644 index 00000000..680e29c3 --- /dev/null +++ b/backend/tests/fixtures/comfyui/example_mapping.json @@ -0,0 +1,13 @@ +{ + "workflow_path": "example_workflow.api.json", + "inputs": { + "positive_prompt": "6.text", + "negative_prompt": "7.text", + "width": "5.width", + "height": "5.height", + "frames": "5.length", + "fps": "8.fps", + "seed": "3.seed" + }, + "output_node": "9" +} diff --git a/backend/tests/fixtures/comfyui/example_workflow.api.json b/backend/tests/fixtures/comfyui/example_workflow.api.json new file mode 100644 index 00000000..690f0084 --- /dev/null +++ b/backend/tests/fixtures/comfyui/example_workflow.api.json @@ -0,0 +1,8 @@ +{ + "3": {"class_type": "KSampler", "inputs": {"seed": 0, "steps": 20, "cfg": 7.0}}, + "5": {"class_type": "EmptyLatentVideo", "inputs": {"width": 512, "height": 512, "length": 24}}, + "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "placeholder positive"}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"text": "placeholder negative"}}, + "8": {"class_type": "VideoCombine", "inputs": {"fps": 24}}, + "9": {"class_type": "SaveVideo", "inputs": {"filename_prefix": "cas"}} +} diff --git a/backend/tests/test_cas_render_api.py b/backend/tests/test_cas_render_api.py new file mode 100644 index 00000000..0d9307ad --- /dev/null +++ b/backend/tests/test_cas_render_api.py @@ -0,0 +1,327 @@ +"""Step 7:渲染启动路由的 HTTP 层测试(走真实 FastAPI 路由与测试客户端)。 + +只伪造 Celery 入队与供应商边界;路由、服务、模型、任务中心均为真实实现。 +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator, Iterator +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +import app.tasks.execute_task as execute_task +from app.core.db import Base +from app.crypto_animal_studio.api import router as cas_router +from app.crypto_animal_studio.production.models import CasProductionJob, CasProductionShot +from app.dependencies import get_db +from app.models.task import GenerationTask +from app.models.task_links import GenerationTaskLink + +JOB_ID = "job-api" +SHOT_ID = "pshot-api" +OTHER_JOB = "job-other" +BASE = "/api/v1/crypto-animal-studio/production" + + +@pytest.fixture() +def client(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[TestClient, async_sessionmaker]]: + """挂载真实 CAS 路由的最小 app;入队被替换为记录器。""" + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.crypto_animal_studio.production.models # noqa: F401 + import app.models.studio # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async def _get_db() -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + @asynccontextmanager + async def _lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with session_factory() as db: + db.add( + CasProductionJob( + id=JOB_ID, project_id="proj-1", episode_id="CAS-EP001", status="running" + ) + ) + db.add( + CasProductionJob( + id=OTHER_JOB, project_id="proj-1", episode_id="CAS-EP002", status="running" + ) + ) + db.add( + CasProductionShot( + id=SHOT_ID, + job_id=JOB_ID, + source_shot_id="SC01", + sequence=1, + status="pending", + duration_seconds=3.0, + video_prompt="Bruno bursts in, arms rising", + ) + ) + db.add( + CasProductionShot( + id="pshot-other", + job_id=OTHER_JOB, + source_shot_id="SC01", + sequence=1, + status="pending", + duration_seconds=3.0, + video_prompt="other episode shot", + ) + ) + await db.commit() + yield + await engine.dispose() + + enqueued: list[str] = [] + monkeypatch.setattr(execute_task, "enqueue_task_execution", lambda task_id: enqueued.append(task_id)) + + app = FastAPI(lifespan=_lifespan) + app.include_router(cas_router, prefix="/api/v1/crypto-animal-studio") + app.dependency_overrides[get_db] = _get_db + with TestClient(app) as test_client: + test_client.enqueued = enqueued # type: ignore[attr-defined] + yield test_client, session_factory + + +def _render(client: TestClient, job=JOB_ID, shot=SHOT_ID): + return client.post(f"{BASE}/jobs/{job}/shots/{shot}/render") + + +# --------------------------------------------------------------------------- # +def test_render_route_creates_task_and_returns_view(client) -> None: + """1+2:请求创建并入队一次尝试,返回 RenderTaskView,且不内联执行供应商。""" + test_client, _factory = client + resp = _render(test_client) + assert resp.status_code == 200 + body = resp.json() + assert {"code", "message", "data"}.issubset(body.keys()) # ApiResponse 壳 + data = body["data"] + assert data["task_id"] + assert data["is_terminal"] is False + assert data["status"] in {"pending", "running"} + # 入队而非内联执行:任务尚未成功,且入队被调用一次 + assert test_client.enqueued == [data["task_id"]] + assert data["provider_task_id"] is None + + +def test_task_link_uses_cas_shot_render_relation(client) -> None: + """9:链接使用正确的 relation_type 与 production_shot_id。""" + test_client, factory = client + _render(test_client) + + import asyncio + + async def _check(): + async with factory() as db: + link = (await db.execute(select(GenerationTaskLink))).scalars().one() + task = (await db.execute(select(GenerationTask))).scalars().one() + return link, task + + link, task = asyncio.run(_check()) + assert link.relation_type == "cas_shot_render" + assert link.relation_entity_id == SHOT_ID + assert task.task_kind == "cas_render_shot" + + +def test_missing_job_returns_404(client) -> None: + """4:任务不存在 → 404。""" + test_client, _ = client + assert _render(test_client, job="nope").status_code == 404 + + +def test_missing_shot_returns_404(client) -> None: + """5:镜头不存在 → 404。""" + test_client, _ = client + assert _render(test_client, shot="nope").status_code == 404 + + +def test_shot_from_another_job_is_rejected(client) -> None: + """6:镜头属于另一个任务 → 拒绝(404),不得跨任务渲染。""" + test_client, _ = client + resp = _render(test_client, job=JOB_ID, shot="pshot-other") + assert resp.status_code == 404 + + +def test_active_attempt_is_returned_idempotently(client) -> None: + """7:已有进行中的尝试 → 返回既有尝试且不重复入队。""" + test_client, _ = client + first = _render(test_client).json()["data"] + second = _render(test_client).json()["data"] + assert second["task_id"] == first["task_id"] + # 只入队了一次 + assert test_client.enqueued == [first["task_id"]] + + +def test_error_responses_contain_no_secrets(client) -> None: + """10:错误响应不含凭据、内网地址、工作流体或堆栈。""" + test_client, _ = client + body = json.dumps(_render(test_client, job="nope").json(), ensure_ascii=False).lower() + for banned in ("api_key", "sk-", "traceback", "class_type", "workflow", "8188"): + assert banned not in body + + +def test_job_listing_lets_workspace_discover_the_job(client) -> None: + """工作台据此定位剧集的生产任务(否则前端拿不到 job_id)。""" + test_client, _ = client + resp = test_client.get(f"{BASE}/jobs", params={"project_id": "proj-1", "episode_id": "CAS-EP001"}) + assert resp.status_code == 200 + jobs = resp.json()["data"] + assert [j["id"] for j in jobs] == [JOB_ID] + assert jobs[0]["shots"][0]["id"] == SHOT_ID + + +def test_job_view_exposes_render_task_after_start(client) -> None: + """3+刷新恢复:GET job 能读回 render_task 投影。""" + test_client, _ = client + started = _render(test_client).json()["data"] + view = test_client.get(f"{BASE}/jobs/{JOB_ID}").json()["data"] + assert view["render_task"]["task_id"] == started["task_id"] + assert view["render_task"]["is_terminal"] is False + + +def test_artifacts_endpoint_returns_empty_list_before_success(client) -> None: + """产物端点在成功前返回空列表,而不是报错。""" + test_client, _ = client + _render(test_client) + resp = test_client.get(f"{BASE}/jobs/{JOB_ID}/artifacts") + assert resp.status_code == 200 + assert resp.json()["data"] == [] + + +def test_multiple_jobs_are_returned_in_deterministic_total_order(client) -> None: + """同项目同剧集有多个任务时,顺序是稳定全序(created_at DESC, id DESC)。""" + test_client, factory = client + import asyncio + + async def _seed_more(): + async with factory() as db: + for suffix in ("aaa", "zzz", "mmm"): + db.add( + CasProductionJob( + id=f"job-{suffix}", + project_id="proj-1", + episode_id="CAS-EP001", + status="running", + ) + ) + await db.commit() + + asyncio.run(_seed_more()) + + params = {"project_id": "proj-1", "episode_id": "CAS-EP001"} + first = [j["id"] for j in test_client.get(f"{BASE}/jobs", params=params).json()["data"]] + second = [j["id"] for j in test_client.get(f"{BASE}/jobs", params=params).json()["data"]] + + assert first == second, "ordering must be reproducible across identical requests" + # 同一剧集的任务全部返回;另一剧集的任务不得混入 + assert set(first) == {JOB_ID, "job-aaa", "job-zzz", "job-mmm"} + assert OTHER_JOB not in first + # created_at 并列时以 id 降序作次级键 → 全序可预测 + assert first[0] == max(first) + + +def test_episode_filter_excludes_unrelated_jobs(client) -> None: + """按 episode_id 过滤后,其它剧集的任务不可能被选中。""" + test_client, _ = client + other = test_client.get( + f"{BASE}/jobs", params={"project_id": "proj-1", "episode_id": "CAS-EP002"} + ).json()["data"] + assert [j["id"] for j in other] == [OTHER_JOB] + + +def test_artifacts_are_scoped_to_the_requested_job(client) -> None: + """产物端点按 job 隔离:另一任务的产物不会出现在本任务下。""" + test_client, factory = client + import asyncio + + from app.crypto_animal_studio.production.models import CasProductionArtifact + + async def _seed_artifacts(): + async with factory() as db: + db.add( + CasProductionArtifact( + id="art-other", + job_id=OTHER_JOB, + production_shot_id="pshot-other", + artifact_type="video", + stage="video_generation", + provider="comfyui", + provider_model="", + file_path="x.mp4", + mime_type="video/mp4", + checksum="", + metadata_json={}, + ) + ) + await db.commit() + + asyncio.run(_seed_artifacts()) + + mine = test_client.get(f"{BASE}/jobs/{JOB_ID}/artifacts").json()["data"] + assert mine == [] + theirs = test_client.get(f"{BASE}/jobs/{OTHER_JOB}/artifacts").json()["data"] + assert [a["id"] for a in theirs] == ["art-other"] + + +def test_chapter_id_resolves_episode_via_import_ledger(client) -> None: + """章节→剧集由 cas_import_ledger 在服务端解析(ChapterRead 不含 episode_id)。""" + test_client, factory = client + import asyncio + + from app.crypto_animal_studio.domain.import_ledger import CasImportLedger + + async def _seed_ledger(): + async with factory() as db: + db.add( + CasImportLedger( + id="led-1", + project_id="proj-1", + episode_id="CAS-EP001", + idempotency_key="k1", + payload_hash="h", + chapter_id="ch-1", + status="imported", + schema_version="1.1", + ) + ) + await db.commit() + + asyncio.run(_seed_ledger()) + + resp = test_client.get( + f"{BASE}/jobs", params={"project_id": "proj-1", "chapter_id": "ch-1"} + ) + assert resp.status_code == 200 + assert [j["id"] for j in resp.json()["data"]] == [JOB_ID] + + +def test_unknown_chapter_returns_empty_not_all_jobs(client) -> None: + """章节没有导入记录时返回空列表,绝不退化成「返回该项目全部任务」。""" + test_client, _ = client + resp = test_client.get( + f"{BASE}/jobs", params={"project_id": "proj-1", "chapter_id": "ch-unknown"} + ) + assert resp.status_code == 200 + assert resp.json()["data"] == [] diff --git a/backend/tests/test_cas_render_e2e.py b/backend/tests/test_cas_render_e2e.py new file mode 100644 index 00000000..0a740dcd --- /dev/null +++ b/backend/tests/test_cas_render_e2e.py @@ -0,0 +1,466 @@ +"""Step 7:假供应商端到端编排测试。 + +**真实的部分**(不打桩):CasProductionJob/Shot、``create_shot_render_task``、 +TaskManager、GenerationTask/GenerationTaskLink、注册到 task_executor_registry 的 +``cas_render_shot`` runner ``run_cas_shot_render_task``、CAS 产物落库、以及 +``render_views`` 读取投影。 + +**被伪造的边界**(明确声明): +1. 供应商 HTTP —— 用假的 ``VideoGenerationTask`` 顶替,不发真实网络请求; +2. 对象存储上传 —— 用假的 ``create_file_from_url_or_b64`` 顶替,但它**真的** + 在数据库里创建 FileItem 行,因此持久化边界仍然是真的; +3. **没有使用 Celery broker**。执行通过与既有任务测试相同的 worker 边界 + (直接调用已注册 runner)驱动。因此这**不是** broker 级 E2E,不作此声明。 +""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +# 必须在打桩之前导入:bootstrap 的 TASK_ADAPTER_SPECS 在模块导入期就引用了 +# 真实的 VideoGenerationTask 静态构造器;若先打桩再首次导入,就会读到假类。 +import app.core.tasks.bootstrap as tasks_bootstrap # noqa: F401 # isort:skip +import app.core.tasks.video_generation_tasks as vgt +import app.utils.files as files_util +from app.core.contracts.video_generation import VideoGenerationResult +from app.core.db import Base, async_session_maker +from app.core.task_manager import SqlAlchemyTaskStore +from app.crypto_animal_studio.application import render_tasks as rt +from app.crypto_animal_studio.application.render_request import build_render_request +from app.crypto_animal_studio.application.render_views import ( + build_artifact_view, + build_render_task_view, + latest_render_task, +) +from app.crypto_animal_studio.production.enums import ArtifactType +from app.crypto_animal_studio.production.models import ( + CasProductionArtifact, + CasProductionJob, + CasProductionShot, +) +from app.models.studio import FileItem, FileType +from app.models.task import GenerationTask +from app.models.task_links import GenerationTaskLink +from app.services.common import create_and_refresh +from app.services.worker.task_registry import task_executor_registry + +def _status_of(row) -> str: + """GenerationTask.status 可能是枚举或字符串,统一取值。""" + status = getattr(row, "status", "") + return status.value if hasattr(status, "value") else str(status) + + +JOB_ID = "job-e2e" +SHOT_ID = "pshot-e2e" + + +# --------------------------------------------------------------------------- # +# 假边界 +# --------------------------------------------------------------------------- # +class _FakeVideoTask: + """假供应商任务:不发网络请求,按脚本返回结果或失败。""" + + script: dict = {"mode": "success", "provider_job_id": "prompt-e2e"} + calls: list = [] + + def __init__(self, **kwargs): + type(self).calls.append(kwargs) + self._result = None + self._error = "" + + async def run(self, *_a, **_k): + mode = type(self).script.get("mode") + if mode == "success": + self._result = VideoGenerationResult( + url="http://provider.invalid/view?filename=out.mp4", + provider_task_id=type(self).script.get("provider_job_id", "prompt-e2e"), + provider="comfyui", + status="succeeded", + ) + else: + self._error = type(self).script.get("error", "boom") + return None + + async def get_result(self): + return self._result + + async def status(self): + return {"error": self._error} + + +async def _fake_create_file(session, *, url=None, name=None, prefix="files", **_kwargs): + """真的写 FileItem 行,只跳过对象存储上传。""" + item = FileItem( + id=str(uuid.uuid4()), + type=FileType.video, + name=name or "video", + thumbnail="", + tags=["cas", "render"], + storage_key=f"{prefix}/{name or 'video'}.mp4", + ) + return await create_and_refresh(session, item) + + +# --------------------------------------------------------------------------- # +# 夹具 +# --------------------------------------------------------------------------- # +async def _make_env(): + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.crypto_animal_studio.production.models # noqa: F401 + import app.models.studio # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as db: + db.add( + CasProductionJob( + id=JOB_ID, project_id="proj-1", episode_id="CAS-EP001", status="running" + ) + ) + db.add( + CasProductionShot( + id=SHOT_ID, + job_id=JOB_ID, + source_shot_id="SC01", + sequence=1, + status="pending", + duration_seconds=3.0, + video_prompt="Bruno bursts in, arms rising", + ) + ) + await db.commit() + return engine, factory + + +def _request(): + class _S: + id = SHOT_ID + source_shot_id = "SC01" + sequence = 1 + duration_seconds = 3.0 + video_prompt = "Bruno bursts in, arms rising" + + return build_render_request(_S(), context={"scene": "The Burrow"}, seed=7) + + +async def _start_attempt(factory) -> str: + """走真实服务创建一次渲染尝试。""" + async with factory() as db: + job = await db.get(CasProductionJob, JOB_ID) + shot = await db.get(CasProductionShot, SHOT_ID) + task_row, _attempt = await rt.create_shot_render_task( + db, + job=job, + production_shot=shot, + render_request=_request(), + provider="comfyui", + base_url="http://comfy.invalid:8188", + ) + await db.commit() + return task_row.id + + +def _run_env(coro_fn): + """在一次事件循环内建环境、跑用例、释放引擎。""" + + async def _wrapper(): + engine, factory = await _make_env() + original = async_session_maker._maker # pylint: disable=protected-access + async_session_maker.configure(factory) + _FakeVideoTask.calls = [] + _FakeVideoTask.script = {"mode": "success", "provider_job_id": "prompt-e2e"} + try: + await coro_fn(factory) + finally: + async_session_maker.configure(original) + await engine.dispose() + + asyncio.run(_wrapper()) + + +@pytest.fixture(autouse=True) +def _fake_boundaries(monkeypatch: pytest.MonkeyPatch): + """只伪造供应商与对象存储两个边界。""" + monkeypatch.setattr(vgt, "VideoGenerationTask", _FakeVideoTask) + monkeypatch.setattr(files_util, "create_file_from_url_or_b64", _fake_create_file) + + +# --------------------------------------------------------------------------- # +# 1–10 主干路径 +# --------------------------------------------------------------------------- # +def test_success_path_creates_single_task_link_file_and_artifact() -> None: + """一次渲染 → 1 个任务、1 条链接、1 个 FileItem、1 条产物,读视图字段齐全。""" + + async def _case(factory): + task_id = await _start_attempt(factory) + + # 3. 创建后立即返回:此时尚未执行,任务仍非终态 + async with factory() as db: + view = build_render_task_view(await db.get(GenerationTask, task_id)) + assert view.is_terminal is False + + await rt.run_cas_shot_render_task(task_id) + + async with factory() as db: + tasks = (await db.execute(select(GenerationTask))).scalars().all() + links = (await db.execute(select(GenerationTaskLink))).scalars().all() + files = (await db.execute(select(FileItem))).scalars().all() + artifacts = (await db.execute(select(CasProductionArtifact))).scalars().all() + final_view = build_render_task_view(await db.get(GenerationTask, task_id)) + artifact_view = build_artifact_view(artifacts[0]) + + assert len(tasks) == 1 and len(links) == 1 + assert links[0].relation_type == "cas_shot_render" + assert links[0].relation_entity_id == SHOT_ID + assert len(files) == 1 + assert len(artifacts) == 1 + art = artifacts[0] + assert art.job_id == JOB_ID and art.production_shot_id == SHOT_ID + assert art.artifact_type == ArtifactType.video.value + + assert final_view.status == "succeeded" + assert final_view.is_terminal is True + assert final_view.progress == 100 + assert final_view.provider_task_id == "prompt-e2e" + + assert artifact_view.file_id == files[0].id + assert artifact_view.download_url == f"/api/v1/studio/files/{files[0].id}/download" + assert artifact_view.provider_job_id == "prompt-e2e" + assert artifact_view.attempt == 1 + # 无真实大小 → None,不伪造 0 + assert artifact_view.size_bytes is None + assert artifact_view.checksum == "" + + _run_env(_case) + + +def test_refresh_recovers_state_purely_from_database() -> None: + """刷新恢复:仅用数据库即可重建最新尝试与产物。""" + + async def _case(factory): + task_id = await _start_attempt(factory) + await rt.run_cas_shot_render_task(task_id) + + async with factory() as db: # 全新会话,模拟刷新 + latest = await latest_render_task(db, production_shot_id=SHOT_ID) + view = build_render_task_view(latest) + arts = ( + await db.execute( + select(CasProductionArtifact).where( + CasProductionArtifact.production_shot_id == SHOT_ID + ) + ) + ).scalars().all() + assert view.task_id == task_id and view.status == "succeeded" + assert len(arts) == 1 + + _run_env(_case) + + +# --------------------------------------------------------------------------- # +# 11–16 幂等 / 重试 / 确定性 +# --------------------------------------------------------------------------- # +def test_redelivery_of_same_task_reuses_artifact() -> None: + """Celery 重投递同一任务:复用既有产物,不新建。""" + + async def _case(factory): + task_id = await _start_attempt(factory) + await rt.run_cas_shot_render_task(task_id) + provider_calls = len(_FakeVideoTask.calls) + + await rt.run_cas_shot_render_task(task_id) # 重投递 + + async with factory() as db: + count = ( + await db.execute(select(func.count()).select_from(CasProductionArtifact)) + ).scalar() + row = await db.get(GenerationTask, task_id) + assert count == 1 + assert (row.result or {}).get("reused") is True + # 幂等短路:没有再次调用供应商 + assert len(_FakeVideoTask.calls) == provider_calls + + _run_env(_case) + + +def test_retry_after_failure_creates_new_attempt_and_preserves_artifacts() -> None: + """失败后重试:新任务+新链接、尝试号递增;既有成功产物不被覆盖。""" + + async def _case(factory): + _FakeVideoTask.script = {"mode": "fail", "error": "ComfyUI execution failed: x"} + first = await _start_attempt(factory) + await rt.run_cas_shot_render_task(first) + + async with factory() as db: + assert _status_of(await db.get(GenerationTask, first)) == "failed" + + _FakeVideoTask.script = {"mode": "success", "provider_job_id": "prompt-2"} + second = await _start_attempt(factory) + await rt.run_cas_shot_render_task(second) + + async with factory() as db: + tasks = (await db.execute(select(GenerationTask))).scalars().all() + links = (await db.execute(select(GenerationTaskLink))).scalars().all() + arts = (await db.execute(select(CasProductionArtifact))).scalars().all() + latest = await latest_render_task(db, production_shot_id=SHOT_ID) + assert len(tasks) == 2 and len(links) == 2 + assert second != first + assert len(arts) == 1 + assert arts[0].metadata_json["attempt"] == 2 + assert latest.id == second + + _run_env(_case) + + +def test_latest_attempt_deterministic_over_three_attempts_ignoring_unrelated_links() -> None: + """三次尝试后 latest 稳定;无关 relation_type 的链接被忽略。""" + + async def _case(factory): + ids = [await _start_attempt(factory) for _ in range(3)] + async with factory() as db: # 无关链接(另一种业务关系) + db.add( + GenerationTaskLink( + task_id=ids[0], + resource_type="task_link", + relation_type="chapter_division", + relation_entity_id=SHOT_ID, + ) + ) + await db.commit() + async with factory() as db: + first = await latest_render_task(db, production_shot_id=SHOT_ID) + again = await latest_render_task(db, production_shot_id=SHOT_ID) + attempts = await rt.count_render_attempts(db, production_shot_id=SHOT_ID) + assert first.id == again.id + assert first.id in ids + assert attempts == 3 # 只计 cas_shot_render + + _run_env(_case) + + +# --------------------------------------------------------------------------- # +# 17–21 取消与失败 +# --------------------------------------------------------------------------- # +def test_cancellation_before_submission_creates_no_artifact() -> None: + """提交前取消:不产生产物,也不调用供应商。""" + + async def _case(factory): + task_id = await _start_attempt(factory) + async with factory() as db: + await SqlAlchemyTaskStore(db).request_cancel(task_id, "user") + await db.commit() + + await rt.run_cas_shot_render_task(task_id) + + async with factory() as db: + count = ( + await db.execute(select(func.count()).select_from(CasProductionArtifact)) + ).scalar() + assert count == 0 + assert _FakeVideoTask.calls == [] + + _run_env(_case) + + +@pytest.mark.parametrize( + "error_text,expected_code", + [ + ("workflow mapping path is not configured", "config"), + ("ComfyUI render timed out after 1800s", "timeout"), + ("ComfyUI execution failed: node 5", "provider"), + ("node 9 produced no video output", "output"), + ], +) +def test_failure_modes_reach_terminal_failed_with_safe_reason( + error_text: str, expected_code: str +) -> None: + """各类失败都落终态 failed,且原因安全。""" + + async def _case(factory): + _FakeVideoTask.script = {"mode": "fail", "error": error_text} + task_id = await _start_attempt(factory) + await rt.run_cas_shot_render_task(task_id) + + async with factory() as db: + row = await db.get(GenerationTask, task_id) + shot = await db.get(CasProductionShot, SHOT_ID) + view = build_render_task_view(row) + count = ( + await db.execute(select(func.count()).select_from(CasProductionArtifact)) + ).scalar() + assert _status_of(row) == "failed" + assert view.is_terminal is True + assert view.error_reason.startswith(expected_code) + assert shot.status == "failed" + assert count == 0 + + _run_env(_case) + + +def test_error_reason_leaks_no_secrets_or_provider_body() -> None: + """失败原因不含凭据、内网地址、工作流体或堆栈。""" + + async def _case(factory): + _FakeVideoTask.script = { + "mode": "fail", + "error": "ComfyUI execution failed: {'api_key':'sk-live-99'," + "'base_url':'http://10.1.2.3:8188','workflow':{'6':{'class_type':'CLIPTextEncode'}}}", + } + task_id = await _start_attempt(factory) + await rt.run_cas_shot_render_task(task_id) + async with factory() as db: + view = build_render_task_view(await db.get(GenerationTask, task_id)) + reason = view.error_reason or "" + for banned in ("sk-live-99", "10.1.2.3", "api_key", "class_type", "Traceback", "{"): + assert banned not in reason + + _run_env(_case) + + +# --------------------------------------------------------------------------- # +# 22–25 既有行为不受影响 +# --------------------------------------------------------------------------- # +def test_no_unique_constraint_exists_on_artifacts() -> None: + """如实记录并发限制:产物表只有普通索引,没有唯一约束。 + + 因此「常规重投递幂等」有测试保证,但**并发**双写在极端情况下仍可能各插一条。 + 这是已知限制,不声称严格 exactly-once。 + """ + constraints = { + type(c).__name__ for c in CasProductionArtifact.__table__.constraints + } + unique_cols = [ + tuple(col.name for col in c.columns) + for c in CasProductionArtifact.__table__.constraints + if type(c).__name__ == "UniqueConstraint" + ] + assert "UniqueConstraint" not in constraints or unique_cols == [] + + +def test_openai_and_volcengine_resolution_unaffected() -> None: + """既有供应商解析不受影响。""" + from app.core.tasks.registry import list_registered_task_adapters + + tasks_bootstrap.bootstrap_task_adapters() + registered = list_registered_task_adapters("video_generation") + assert ("video_generation", "openai") in registered + assert ("video_generation", "volcengine") in registered + assert ("video_generation", "comfyui") in registered + + +def test_worker_boundary_is_the_registered_executor() -> None: + """执行入口确实是注册在既有 registry 上的 runner(非测试专用捷径)。""" + executor = task_executor_registry.resolve("cas_render_shot") + assert executor.task_kind == "cas_render_shot" + assert executor._runner is rt.run_cas_shot_render_task # pylint: disable=protected-access diff --git a/backend/tests/test_cas_render_request.py b/backend/tests/test_cas_render_request.py new file mode 100644 index 00000000..6fa6ac08 --- /dev/null +++ b/backend/tests/test_cas_render_request.py @@ -0,0 +1,122 @@ +"""Step 7:单镜头渲染请求构造测试(确定性 + 缺字段容错 + 快照安全)。""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from app.crypto_animal_studio.application.render_request import ( + DEFAULT_NEGATIVE_PROMPT, + SNAPSHOT_VERSION, + build_render_request, + snapshot_fingerprint, +) + + +@dataclass +class _Shot: + id: str = "ps-1" + source_shot_id: str = "SC01" + sequence: int = 1 + duration_seconds: float = 3.0 + video_prompt: str = "" + + +def _context() -> dict: + return { + "visual_style": "premium stylized 3D", + "shot_type": "medium wide", + "camera_angle": "slight low angle", + "camera_movement": "push in", + "scene": "The Burrow trading office at night", + "characters": "Bruno Bull, forest-green shirt, mustard tie", + "action": "Bruno shoulders through the doorway, both arms rising", + "beginning_state": "door half open", + "ending_state": "arms up, hooves planted", + "atmosphere": "cool office ambience with green chart accent", + "continuity_notes": "smartwatch on left wrist", + } + + +def test_prompt_is_deterministic_for_identical_inputs() -> None: + """相同输入 → 逐字节相同的提示词与快照指纹。""" + first = build_render_request(_Shot(), context=_context()) + second = build_render_request(_Shot(), context=_context()) + assert first.prompt == second.prompt + assert snapshot_fingerprint(first.snapshot) == snapshot_fingerprint(second.snapshot) + + +def test_sections_appear_in_fixed_order() -> None: + """段落顺序固定,不随 dict 插入顺序变化。""" + ctx = _context() + shuffled = {key: ctx[key] for key in reversed(list(ctx))} + assert build_render_request(_Shot(), context=ctx).prompt == ( + build_render_request(_Shot(), context=shuffled).prompt + ) + prompt = build_render_request(_Shot(), context=ctx).prompt + assert prompt.index("premium stylized 3D") < prompt.index("The Burrow") + assert prompt.index("The Burrow") < prompt.index("shoulders through the doorway") + + +def test_optional_fields_may_be_missing() -> None: + """只有 action 也能构造;缺失段落被跳过而不是留下空句。""" + request = build_render_request(_Shot(), context={"action": "Milo lowers the mug"}) + assert request.prompt == "Milo lowers the mug." + assert ".." not in request.prompt + assert request.negative_prompt == DEFAULT_NEGATIVE_PROMPT + + +def test_shot_video_prompt_overrides_context_action() -> None: + """镜头自带 video_prompt 时优先于上下文 action。""" + shot = _Shot(video_prompt="explicit provider-facing action text") + request = build_render_request(shot, context={"action": "ignored"}) + assert "explicit provider-facing action text" in request.prompt + assert "ignored" not in request.prompt + + +def test_missing_all_visual_fields_is_rejected() -> None: + """完全没有可成像描述时明确失败,而不是提交空提示词。""" + with pytest.raises(ValueError, match="no action, scene or character"): + build_render_request(_Shot(), context={"atmosphere": "moody"}) + + +def test_duration_rounds_to_at_least_one_second() -> None: + """时长取整且下限为 1 秒。""" + assert build_render_request(_Shot(duration_seconds=6.5), context=_context()).seconds == 6 + assert build_render_request(_Shot(duration_seconds=0.2), context=_context()).seconds == 1 + assert build_render_request(_Shot(duration_seconds=0), context=_context()).seconds == 1 + + +def test_video_input_conversion_matches_request() -> None: + """转换为供应商中立契约时字段一致。""" + request = build_render_request(_Shot(), context=_context(), seed=42) + video_input = request.to_video_input() + assert video_input.prompt == request.prompt + assert video_input.ratio == "9:16" + assert video_input.seconds == 3 + assert video_input.seed == 42 + + +def test_snapshot_is_reproducible_and_free_of_secrets() -> None: + """快照可复现,且不含密钥或整个工作流负载。""" + request = build_render_request(_Shot(), context=_context(), seed=7) + snapshot = request.snapshot + assert snapshot["snapshot_version"] == SNAPSHOT_VERSION + assert snapshot["source_shot_id"] == "SC01" + assert snapshot["seed"] == 7 + # prompt_sha256 必须是该提示词的真实摘要,而不仅仅是「有个 64 字符的串」 + import hashlib + + assert snapshot["prompt_sha256"] == hashlib.sha256(request.prompt.encode("utf-8")).hexdigest() + # 快照只保留段落文本,不含供应商负载/节点图/凭据 + for banned in ("api_key", "workflow", "class_type", "base_url", "token", "password"): + assert banned not in str(snapshot).lower() + + +def test_seed_change_changes_fingerprint_but_not_prompt() -> None: + """种子影响可复现性指纹,但不改变提示词文本。""" + a = build_render_request(_Shot(), context=_context(), seed=1) + b = build_render_request(_Shot(), context=_context(), seed=2) + assert a.prompt == b.prompt + assert snapshot_fingerprint(a.snapshot) != snapshot_fingerprint(b.snapshot) diff --git a/backend/tests/test_cas_render_tasks.py b/backend/tests/test_cas_render_tasks.py new file mode 100644 index 00000000..676609af --- /dev/null +++ b/backend/tests/test_cas_render_tasks.py @@ -0,0 +1,317 @@ +"""Step 7 任务 #40:CAS 单镜头渲染的执行接线测试。 + +用假供应商/假 HTTP 边界,生产执行路径本身保持真实。 +""" + +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core.db import Base, async_session_maker +from app.core.task_manager.types import TaskStatus +from app.crypto_animal_studio.application import render_tasks as rt +from app.crypto_animal_studio.application.render_request import build_render_request +from app.crypto_animal_studio.production.enums import ArtifactType, JobStatus +from app.crypto_animal_studio.production.models import ( + CasProductionArtifact, + CasProductionJob, + CasProductionShot, +) +from app.models.task import GenerationTask +from app.models.task_links import GenerationTaskLink +from app.services.worker.task_registry import task_executor_registry + + +async def _make_sessionmaker(): + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.crypto_animal_studio.domain.import_ledger # noqa: F401 + import app.crypto_animal_studio.production.models # noqa: F401 + import app.models.llm # noqa: F401 + import app.models.studio # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +async def _seed(session_factory) -> tuple[str, str]: + async with session_factory() as db: + job = CasProductionJob( + id="job-1", project_id="proj-1", episode_id="CAS-EP001", status="running" + ) + db.add(job) + shot = CasProductionShot( + id="pshot-1", + job_id="job-1", + source_shot_id="SC01", + sequence=1, + status="pending", + duration_seconds=3.0, + video_prompt="Bruno bursts in, arms rising", + ) + db.add(shot) + await db.commit() + return "job-1", "pshot-1" + + +def _request(shot_id: str = "SC01"): + class _S: + id = "pshot-1" + source_shot_id = shot_id + sequence = 1 + duration_seconds = 3.0 + video_prompt = "Bruno bursts in, arms rising" + + return build_render_request(_S(), context={"scene": "The Burrow"}, seed=7) + + +class _FakeFile: + def __init__(self, file_id="file-1", key="cas/renders/job-1/pshot-1/v.mp4"): + self.id = file_id + self.storage_key = key + + +# --------------------------------------------------------------------------- # +# 1. 注册与解析 +# --------------------------------------------------------------------------- # +def test_executor_is_registered_in_existing_registry() -> None: + """使用既有 registry,不新建队列体系。""" + executor = task_executor_registry.resolve(rt.CAS_RENDER_SHOT_TASK_KIND) + assert executor.task_kind == "cas_render_shot" + assert executor.timeout_seconds == 3600.0 + + +def test_existing_video_generation_executor_unaffected() -> None: + """既有 video_generation 执行器仍可解析(OpenAI/火山路径不受影响)。""" + assert task_executor_registry.resolve("video_generation") is not None + + +# --------------------------------------------------------------------------- # +# 2. 创建渲染尝试 +# --------------------------------------------------------------------------- # +def test_create_task_records_link_and_snapshot() -> None: + """创建任务:登记 GenerationTaskLink,run_args 携带快照且不含工作流负载。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + job = await db.get(CasProductionJob, "job-1") + shot = await db.get(CasProductionShot, "pshot-1") + task_row, attempt = await rt.create_shot_render_task( + db, + job=job, + production_shot=shot, + render_request=_request(), + provider="comfyui", + base_url="http://comfy.test:8188", + ) + await db.commit() + + assert attempt == 1 + assert task_row.task_kind == "cas_render_shot" + async with session_factory() as db: + link = (await db.execute(select(GenerationTaskLink))).scalars().one() + assert link.relation_type == "cas_shot_render" + assert link.relation_entity_id == "pshot-1" + row = await db.get(GenerationTask, task_row.id) + run_args = (row.payload or {}).get("run_args") or {} + assert run_args["input"]["ratio"] == "9:16" + assert run_args["request_snapshot"]["prompt_sha256"] + # 快照不含工作流负载/凭据 + assert "class_type" not in str(run_args["request_snapshot"]) + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_attempts_increment_for_retry() -> None: + """重试产生可追溯的新尝试序号。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + for expected in (1, 2, 3): + async with session_factory() as db: + job = await db.get(CasProductionJob, "job-1") + shot = await db.get(CasProductionShot, "pshot-1") + _task, attempt = await rt.create_shot_render_task( + db, + job=job, + production_shot=shot, + render_request=_request(), + provider="comfyui", + base_url="http://c", + ) + await db.commit() + assert attempt == expected + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_active_task_is_detected() -> None: + """存在非终态尝试时可被检出(用于禁用重复提交)。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + job = await db.get(CasProductionJob, "job-1") + shot = await db.get(CasProductionShot, "pshot-1") + await rt.create_shot_render_task( + db, + job=job, + production_shot=shot, + render_request=_request(), + provider="comfyui", + base_url="http://c", + ) + await db.commit() + async with session_factory() as db: + assert await rt.find_active_render_task(db, production_shot_id="pshot-1") + finally: + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- # +# 3. 产物登记与幂等 +# --------------------------------------------------------------------------- # +def test_artifact_links_job_and_shot_with_safe_metadata() -> None: + """产物关联 job + 生产镜头,并记录安全的供应商元数据。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + artifact, created = await rt.persist_render_artifact( + db, + job_id="job-1", + production_shot_id="pshot-1", + file_item=_FakeFile(), + provider="comfyui", + provider_job_id="prompt-abc", + attempt=1, + request_snapshot=_request().snapshot, + size_bytes=12345, + ) + await db.commit() + assert created is True + assert artifact.job_id == "job-1" + assert artifact.production_shot_id == "pshot-1" + assert artifact.artifact_type == ArtifactType.video.value + assert artifact.mime_type == "video/mp4" + assert artifact.metadata_json["provider_job_id"] == "prompt-abc" + assert artifact.metadata_json["file_id"] == "file-1" + assert artifact.metadata_json["size_bytes"] == 12345 + for banned in ("api_key", "token", "password", "base_url"): + assert banned not in str(artifact.metadata_json).lower() + finally: + await engine.dispose() + + asyncio.run(_run()) + + +def test_repeated_completion_does_not_duplicate_artifact() -> None: + """重复完成/重投递不会产生第二条成功产物,且保留最初的产物。""" + + async def _run() -> None: + engine, session_factory = await _make_sessionmaker() + try: + await _seed(session_factory) + async with session_factory() as db: + first, created_1 = await rt.persist_render_artifact( + db, + job_id="job-1", + production_shot_id="pshot-1", + file_item=_FakeFile("file-1"), + provider="comfyui", + provider_job_id="p1", + attempt=1, + request_snapshot={}, + ) + await db.commit() + async with session_factory() as db: + second, created_2 = await rt.persist_render_artifact( + db, + job_id="job-1", + production_shot_id="pshot-1", + file_item=_FakeFile("file-2"), + provider="comfyui", + provider_job_id="p2", + attempt=2, + request_snapshot={}, + ) + await db.commit() + + assert created_1 is True and created_2 is False + assert second.id == first.id + # 既有成功产物保持不变(未被第二次尝试覆盖) + assert second.metadata_json["file_id"] == "file-1" + assert second.metadata_json["provider_job_id"] == "p1" + async with session_factory() as db: + total = ( + await db.execute(select(func.count()).select_from(CasProductionArtifact)) + ).scalar() + assert total == 1 + finally: + await engine.dispose() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- # +# 4. 失败映射(安全性) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "exc_name,message,expected_code", + [ + ("WorkflowConfigError", "workflow mapping path is not configured", "config"), + ("ComfyUIError", "ComfyUI render timed out after 1800s", "timeout"), + ("ComfyUIError", "node 5 produced no video output", "output"), + ("ComfyUIError", "ComfyUI execution failed: bad node", "provider"), + ("ConnectError", "connection refused", "network"), + ("RuntimeError", "unexpected", "unknown"), + ], +) +def test_failure_classification(exc_name: str, message: str, expected_code: str) -> None: + """异常按类型/关键词映射为结构化错误码。""" + exc = type(exc_name, (Exception,), {})(message) + code, safe = rt.classify_failure(exc) + assert code == expected_code + assert safe == rt._SAFE_FAILURE_MESSAGES[expected_code] # pylint: disable=protected-access + + +def test_failure_message_never_leaks_provider_details() -> None: + """供应商响应体、地址与凭据都不得出现在用户可见文案中。""" + leaky = type("ComfyUIError", (Exception,), {})( + "ComfyUI execution failed: {'api_key': 'sk-secret', " + "'base_url': 'http://10.0.0.5:8188', 'workflow': {...}}" + ) + _code, safe = rt.classify_failure(leaky) + lowered = safe.lower() + for banned in ("sk-secret", "10.0.0.5", "api_key", "workflow", "traceback", "{"): + assert banned not in lowered + + +def test_shot_status_constants_are_used() -> None: + """失败/成功写入的镜头状态取自既有枚举,不引入新字符串。""" + assert JobStatus.completed.value == "completed" + assert JobStatus.failed.value == "failed" diff --git a/backend/tests/test_cas_render_views.py b/backend/tests/test_cas_render_views.py new file mode 100644 index 00000000..59d35bc9 --- /dev/null +++ b/backend/tests/test_cas_render_views.py @@ -0,0 +1,215 @@ +"""Step 7:渲染读取契约(任务视图 / 产物视图 / 模式校验)测试。""" + +from __future__ import annotations + +import asyncio + +import pytest +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.core.db import Base +from app.crypto_animal_studio.application import render_tasks as rt +from app.crypto_animal_studio.application.render_views import ( + TERMINAL_TASK_STATUSES, + build_artifact_view, + build_render_task_view, + latest_render_task, + stage_message_for, +) +from app.crypto_animal_studio.production.models import CasProductionJob, CasProductionShot +from app.crypto_animal_studio.schemas.production import ( + CreateProductionJobRequest, + ProductionArtifactView, +) + + +class _Artifact: + id = "a1" + production_shot_id = "ps1" + artifact_type = "video" + stage = "video_generation" + provider = "comfyui" + provider_model = "" + file_path = "cas/renders/job-1/ps1/v.mp4" + mime_type = "video/mp4" + checksum = "" + metadata_json = { + "file_id": "file-1", + "size_bytes": 2048, + "provider_job_id": "prompt-xyz", + "attempt": 2, + } + + +class _Task: + def __init__(self, status="running", progress=80, result=None, error=""): + self.id = "task-1" + self.status = status + self.progress = progress + self.result = result or {} + self.error = error + + +# --------------------------------------------------------------------------- # +# 请求契约 +# --------------------------------------------------------------------------- # +def test_mode_accepts_mock_and_render_only() -> None: + """render 必须显式选择;mock 保持缺省,未知值被拒绝。""" + package = {"schema_version": "1.0"} + assert CreateProductionJobRequest.model_fields["mode"].default == "mock" + with pytest.raises(ValidationError): + CreateProductionJobRequest(project_id="p", episode_package=package, mode="real") + + +# --------------------------------------------------------------------------- # +# 产物视图 +# --------------------------------------------------------------------------- # +def test_artifact_view_exposes_playable_url_via_existing_endpoint() -> None: + """播放地址复用既有受控文件端点,不新开公开静态路由。""" + view = build_artifact_view(_Artifact()) + assert view.file_id == "file-1" + assert view.download_url == "/api/v1/studio/files/file-1/download" + assert view.size_bytes == 2048 + assert view.provider_job_id == "prompt-xyz" + assert view.attempt == 2 + assert view.mime_type == "video/mp4" + + +def test_artifact_view_without_file_id_has_no_download_url() -> None: + """没有 FileItem 时不编造下载地址。""" + + class _NoFile(_Artifact): + metadata_json: dict = {} + + view = build_artifact_view(_NoFile()) + assert view.file_id is None + assert view.download_url is None + assert view.size_bytes is None + + +def test_checksum_stays_empty_and_is_documented() -> None: + """对象存储产物没有可用的本地校验和 —— 保持空串而不是编造。 + + 幂等性因此由「job+shot+type 的产物存在性检查」保证(见 test_cas_render_tasks)。 + """ + view = build_artifact_view(_Artifact()) + assert view.checksum == "" + + +def test_step6_artifact_shape_remains_valid() -> None: + """Step 6 的字段集合(不含 Step 7 可选字段)依然构造成功。""" + view = ProductionArtifactView( + id="a", + production_shot_id=None, + artifact_type="manifest", + stage="finalize", + provider="", + provider_model="", + file_path="p", + mime_type="application/json", + checksum="abc", + ) + assert view.file_id is None and view.download_url is None + + +# --------------------------------------------------------------------------- # +# 任务视图 +# --------------------------------------------------------------------------- # +def test_render_task_view_maps_progress_and_stage() -> None: + """进度映射到安全阶段文案,终态标记正确。""" + running = build_render_task_view(_Task(status="running", progress=80)) + assert running.stage_message == "Downloading generated video" + assert running.is_terminal is False + + done = build_render_task_view( + _Task(status="succeeded", progress=100, result={"provider_job_id": "p1", "attempt": 1}) + ) + assert done.is_terminal is True + assert done.provider_task_id == "p1" + assert done.attempt == 1 + + +def test_render_task_view_is_none_without_attempts() -> None: + """从未渲染过的镜头返回 None,而不是伪造一个 pending 任务。""" + assert build_render_task_view(None) is None + + +def test_failed_task_view_exposes_only_safe_reason() -> None: + """失败原因来自写入阶段已脱敏的文案,不含堆栈或凭据。""" + view = build_render_task_view( + _Task(status="failed", progress=20, error="provider: The render provider reported an execution failure.") + ) + assert view.is_terminal is True + assert "Traceback" not in (view.error_reason or "") + assert "sk-" not in (view.error_reason or "") + assert view.stage_message == "Failed" + + +def test_terminal_status_set_matches_task_center_values() -> None: + """终态集合与任务中心的状态取值一致。""" + assert TERMINAL_TASK_STATUSES == frozenset({"succeeded", "failed", "cancelled"}) + assert stage_message_for("cancelled", 20) == "Cancelled" + + +# --------------------------------------------------------------------------- # +# 最近尝试的确定性选取 +# --------------------------------------------------------------------------- # +def test_latest_render_task_is_deterministic_across_retries() -> None: + """多次重试后,latest_render_task 稳定返回最近一次尝试。""" + + async def _run() -> None: + engine = create_async_engine( + "sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + import app.crypto_animal_studio.production.models # noqa: F401 + import app.models.studio # noqa: F401 + import app.models.task # noqa: F401 + import app.models.task_links # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with session_factory() as db: + db.add(CasProductionJob(id="job-1", project_id="p", episode_id="E", status="running")) + db.add( + CasProductionShot( + id="ps1", job_id="job-1", source_shot_id="SC01", sequence=1, + status="pending", duration_seconds=3.0, + ) + ) + await db.commit() + + class _Req: + snapshot = {"prompt_sha256": "x"} + + @staticmethod + def to_video_input(): + from app.core.contracts.video_generation import VideoGenerationInput + + return VideoGenerationInput(prompt="p", ratio="9:16", seconds=3) + + ids = [] + for _ in range(3): + async with session_factory() as db: + job = await db.get(CasProductionJob, "job-1") + shot = await db.get(CasProductionShot, "ps1") + task_row, _attempt = await rt.create_shot_render_task( + db, job=job, production_shot=shot, render_request=_Req(), + provider="comfyui", base_url="http://c", + ) + ids.append(task_row.id) + await db.commit() + + async with session_factory() as db: + latest = await latest_render_task(db, production_shot_id="ps1") + again = await latest_render_task(db, production_shot_id="ps1") + assert latest is not None + assert latest.id == again.id # 稳定 + assert latest.id in ids + finally: + await engine.dispose() + + asyncio.run(_run()) diff --git a/backend/tests/test_comfyui_provider.py b/backend/tests/test_comfyui_provider.py new file mode 100644 index 00000000..7d278602 --- /dev/null +++ b/backend/tests/test_comfyui_provider.py @@ -0,0 +1,244 @@ +"""Step 7:ComfyUI 供应商适配层测试(无需真实 ComfyUI 实例)。 + +覆盖:工作流映射加载与校验、输入注入(不假设节点 ID)、提交/轮询/完成解析、 +失败与超时映射、产物定位、配置缺失的清晰失败、以及错误信息不泄露密钥。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.core.contracts.provider import ProviderConfig +from app.core.contracts.video_generation import VideoGenerationInput +from app.core.integrations.comfyui import ( + ComfyUIError, + WorkflowConfigError, + apply_inputs, + extract_video_output, + load_mapping, + read_execution_status, +) +from app.core.tasks.video_generation_tasks import ComfyUIVideoGenerationTask + +_FIXTURES = Path(__file__).resolve().parent / "fixtures" / "comfyui" +_MAPPING = _FIXTURES / "example_mapping.json" + + +def _cfg(base_url: str | None = "http://comfy.test:8188") -> ProviderConfig: + return ProviderConfig(provider="comfyui", api_key="", base_url=base_url) + + +def _input(**overrides) -> VideoGenerationInput: + data = {"prompt": "Bruno bursts in, arms rising", "ratio": "9:16", "seconds": 3} + data.update(overrides) + return VideoGenerationInput(**data) + + +class _FakeAdapter: + """假 HTTP 边界:记录提交内容并按脚本返回 history。""" + + def __init__(self, history_sequence: list[dict | None], *, fail_submit: str | None = None): + self.history_sequence = list(history_sequence) + self.fail_submit = fail_submit + self.submitted_prompt: dict | None = None + self.submit_calls = 0 + self.history_calls = 0 + + async def submit_prompt(self, *, cfg, prompt, client_id, timeout_s): + self.submit_calls += 1 + self.submitted_prompt = prompt + if self.fail_submit: + raise ComfyUIError(self.fail_submit) + return "prompt-abc123" + + async def get_history(self, *, cfg, prompt_id, timeout_s): + self.history_calls += 1 + if self.history_sequence: + return self.history_sequence.pop(0) + return None + + def build_view_url(self, *, cfg, output): + return f"{cfg.base_url}/view?filename={output['filename']}" + + +def _success_history() -> dict: + return { + "status": {"status_str": "success", "completed": True}, + "outputs": {"9": {"videos": [{"filename": "cas_00001.mp4", "subfolder": "", "type": "output"}]}}, + } + + +# --------------------------------------------------------------------------- # +# 工作流映射 +# --------------------------------------------------------------------------- # +def test_mapping_loads_and_describes_safely() -> None: + """映射可加载,摘要不含提示词或密钥。""" + mapping = load_mapping(_MAPPING) + assert mapping.output_node == "9" + assert set(mapping.inputs) == { + "positive_prompt", + "negative_prompt", + "width", + "height", + "frames", + "fps", + "seed", + } + # describe() 只暴露结构信息:节点数、被映射的输入名、输出节点。 + # 断言它不含任何工作流内容(提示词文本、模型名等),可安全写入日志与任务元数据。 + described = mapping.describe() + assert described["output_node"] == "9" + assert described["node_count"] == 6 + serialized = json.dumps(described) + assert "placeholder positive" not in serialized + assert "CLIPTextEncode" not in serialized + assert set(described) == {"node_count", "mapped_inputs", "output_node"} + + +def test_apply_inputs_injects_only_mapped_values_without_mutating_source() -> None: + """只注入被映射且有值的键;原工作流不被修改(不假设节点 ID)。""" + mapping = load_mapping(_MAPPING) + before = json.dumps(mapping.workflow, sort_keys=True) + prompt = apply_inputs( + mapping, + {"positive_prompt": "a bull celebrates", "width": 1080, "height": 1920, "seed": None}, + ) + assert prompt["6"]["inputs"]["text"] == "a bull celebrates" + assert prompt["5"]["inputs"]["width"] == 1080 + assert prompt["5"]["inputs"]["height"] == 1920 + # seed 为 None → 不注入,保留工作流原值 + assert prompt["3"]["inputs"]["seed"] == 0 + assert json.dumps(mapping.workflow, sort_keys=True) == before + + +def test_mapping_rejects_unknown_node_reference(tmp_path: Path) -> None: + """映射指向不存在的节点时明确失败。""" + bad = tmp_path / "m.json" + bad.write_text( + json.dumps( + { + "workflow_path": str(_FIXTURES / "example_workflow.api.json"), + "inputs": {"positive_prompt": "999.text"}, + "output_node": "9", + } + ), + encoding="utf-8", + ) + with pytest.raises(WorkflowConfigError, match="absent from the workflow"): + load_mapping(bad) + + +def test_missing_mapping_file_fails_clearly(tmp_path: Path) -> None: + """配置文件缺失 → 清晰错误,绝不回退到假供应商。""" + with pytest.raises(WorkflowConfigError, match="not found"): + load_mapping(tmp_path / "nope.json") + + +def test_unconfigured_workflow_path_fails_clearly() -> None: + """未配置映射路径时构造任务即失败。""" + with pytest.raises(WorkflowConfigError, match="not configured"): + ComfyUIVideoGenerationTask( + provider_config=_cfg(), input_=_input(), workflow_mapping_path="" + ) + + +# --------------------------------------------------------------------------- # +# 提交与轮询 +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_submit_and_complete_produces_result() -> None: + """排队 → 完成:返回可下载 URL 与 provider job id。""" + adapter = _FakeAdapter([None, _success_history()]) + task = ComfyUIVideoGenerationTask( + adapter=adapter, + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(), + poll_interval_s=0, + timeout_s=5, + ) + await task.run() + result = await task.get_result() + + assert result is not None, await task.status() + assert result.provider == "comfyui" + assert result.provider_task_id == "prompt-abc123" + assert result.url.endswith("cas_00001.mp4") + # 9:16 → 1080×1920;3 秒 @24fps → 72 帧 + assert adapter.submitted_prompt["5"]["inputs"]["width"] == 1080 + assert adapter.submitted_prompt["5"]["inputs"]["height"] == 1920 + assert adapter.submitted_prompt["5"]["inputs"]["length"] == 72 + assert adapter.submitted_prompt["8"]["inputs"]["fps"] == 24 + + +@pytest.mark.asyncio +async def test_provider_error_is_mapped_to_failure() -> None: + """供应商报错 → 任务失败且错误可读。""" + history = {"status": {"status_str": "error", "messages": ["node 5 failed"]}} + task = ComfyUIVideoGenerationTask( + adapter=_FakeAdapter([history]), + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(), + poll_interval_s=0, + timeout_s=5, + ) + await task.run() + assert await task.get_result() is None + status = await task.status() + assert "execution failed" in status["error"] + + +@pytest.mark.asyncio +async def test_timeout_is_mapped_to_structured_failure() -> None: + """永远排队 → 超时失败,不会无限挂起。""" + task = ComfyUIVideoGenerationTask( + adapter=_FakeAdapter([]), + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(), + poll_interval_s=0, + timeout_s=0, + ) + await task.run() + assert await task.get_result() is None + assert "timed out" in (await task.status())["error"] + + +@pytest.mark.asyncio +async def test_missing_base_url_fails_without_leaking_config() -> None: + """未配置 base_url → 明确失败,且信息里不含密钥字段。""" + from app.core.integrations.comfyui import ComfyUIVideoApiAdapter + + adapter = ComfyUIVideoApiAdapter() + with pytest.raises(ComfyUIError, match="base_url is not configured"): + await adapter.submit_prompt( + cfg=_cfg(base_url=None), prompt={}, client_id="c", timeout_s=1 + ) + + +# --------------------------------------------------------------------------- # +# 产物定位与状态解析 +# --------------------------------------------------------------------------- # +def test_extract_video_output_rejects_non_video() -> None: + """节点只产出图片时不得当作视频成功。""" + entry = {"outputs": {"9": {"images": [{"filename": "preview.png"}]}}} + with pytest.raises(ComfyUIError, match="no video output"): + extract_video_output(entry, "9") + + +def test_extract_video_output_finds_video_across_collections() -> None: + """videos/gifs/images/files 任一集合中的视频都能定位。""" + entry = {"outputs": {"9": {"gifs": [{"filename": "out.webm", "subfolder": "sub"}]}}} + found = extract_video_output(entry, "9") + assert found["filename"] == "out.webm" + assert found["subfolder"] == "sub" + + +def test_read_execution_status_treats_absent_status_as_running() -> None: + """尚无 status 字段视为仍在执行,而不是失败。""" + assert read_execution_status({})[0] == "running" + assert read_execution_status({"status": {"status_str": "success"}})[0] == "success" diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 6703dc56..b4a9831c 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -361,3 +361,95 @@ an early return. Not touched here — outside Step 6 scope. **Step 6 is accepted.** The EP001 production workspace, the `chapter_id` / `usage_kind` file filters, the Vitest + React Testing Library infrastructure and the regenerated OpenAPI client are frozen alongside the Step 5 / 5.1 importer, subtitle artifact pipeline and worker registration. + +--- + +## Step 7 — EP001 First Real Render + +### Architecture + +``` +CasProductionShot → build_render_request → CasProductionJob + → TaskManager (task_kind="cas_render_shot") + GenerationTaskLink("cas_shot_render") + → enqueue_task_execution → existing Celery task.execute + → registered cas_render_shot executor → run_cas_shot_render_task + → existing VideoGenerationTask → resolve_task_adapter("video_generation", "comfyui") + → ComfyUI (/prompt → /history → /view) + → create_file_from_url_or_b64 → FileItem + → CasProductionArtifact → render_views → EP001 Workspace +``` + +`cas_render_shot` is a **CAS-specific persistence tail on shared infrastructure** — the same +`task_executor_registry`, the same `AbstractAsyncDelegatingExecutor`, the same Celery entry point +and the same `VideoGenerationTask` provider dispatch. It is **not** a second queue, a second +provider registry or a parallel execution system. The tail differs only because +`persist_generated_video_to_shot` is hard-wired to the Jellyfish `Shot` model, so reusing it +verbatim could not link a `CasProductionArtifact`. The Jellyfish film path is untouched. + +### Configuration + +| Variable | Default | Notes | +|---|---|---| +| `CAS_RENDER_PROVIDER` | `comfyui` | `comfyui` / `volcengine` / `openai` | +| `CAS_COMFYUI_BASE_URL` | *(none)* | e.g. `http://127.0.0.1:8188`; no default, never hard-coded | +| `CAS_COMFYUI_WORKFLOW_MAPPING` | *(none)* | path to the mapping JSON | +| `CAS_RENDER_POLL_INTERVAL_S` | `3.0` | provider poll interval | +| `CAS_RENDER_TIMEOUT_S` | `1800.0` | render timeout → structured failure | + +**Startup safety:** the app imports and runs with none of these set. Adapter registration is +**lazy** (`list_registered_task_adapters` is empty until bootstrap runs inside +`VideoGenerationTask.__init__`), OpenAI and Volcengine resolve unchanged, and ComfyUI raises +`WorkflowConfigError` **only when selected**. + +**Workflow requirement:** ComfyUI must be exported in **API format**. Node IDs are never assumed; +a mapping JSON declares `"": "."` plus `output_node`. See +`backend/tests/fixtures/comfyui/example_mapping.json`. Only mapped, non-null inputs are injected, +so workflows lacking negative-prompt / fps / seed still work. + +**Startup order:** ComfyUI → API server → Celery worker. + +### Status, attempts and the latest-attempt rule + +Progress ladder `5 → 20 → 80 → 100` maps to `Worker started → Submitted to render provider → +Downloading generated video → Completed`. Terminal states: `succeeded`, `failed`, `cancelled`. + +**Latest attempt is selected by `GenerationTaskLink.id DESC`, filtered to +`relation_type == "cas_shot_render"`.** `created_at` plus the random task UUID is explicitly +**not** used: two attempts created in the same second tie on timestamp, and UUID ordering does not +represent creation order — that rule was stable but selected the *wrong* attempt, found by the +end-to-end retry test. + +### Artifact lifecycle and limitations + +`FileItem` is created through the existing storage abstraction; `CasProductionArtifact` links job + +production shot. Playback uses the existing controlled endpoint +`/api/v1/studio/files/{file_id}/download` — never a URL built from `storage_key`, and no public +static route was added. + +- **`checksum` stays `""`** — `FileItem` has no checksum column and the object is not re-downloaded + merely to hash it. The DB-level artifact existence check is the explicit idempotency mechanism. +- **`size_bytes` stays `None`** when no real persisted size exists. Never estimated, never zero. +- **Redelivery** of a successful task reuses the artifact and does not call the provider again. +- **Retry** creates a new `GenerationTask` + link with an incremented attempt; earlier successful + artifacts are preserved, never overwritten. +- **Concurrency limitation:** `cas_production_artifacts` has an `Index`, **not** a + `UniqueConstraint`. Ordinary redelivery idempotency is tested and works, but two *truly + concurrent* transactions could each insert one row. Strict exactly-once is **not** claimed and no + migration was added. + +### Testing boundaries + +Automated E2E fakes exactly two boundaries: provider HTTP (`VideoGenerationTask`) and the external +object-storage upload — though the storage fake still writes a **real `FileItem` row**. Everything +else is the real path. **No Celery broker was exercised**, so this is *worker-boundary* E2E, not +broker-level E2E. + +### Status + +- **Implementation complete** — backend and frontend source written. +- **Automated backend verification complete** — see the commands below. +- **Frontend verification BLOCKED** — `node_modules` cannot be created on this mount. +- **Real-provider render BLOCKED** — no API-reachable ComfyUI and no compatible workflow. + +Real-render acceptance requires exactly one action: start an API-reachable ComfyUI instance and +export a compatible API-format video workflow JSON together with its node mapping. diff --git a/front/pnpm-lock.yaml b/front/pnpm-lock.yaml index ec9c2969..76b9b45a 100644 --- a/front/pnpm-lock.yaml +++ b/front/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 5.0.11(@types/react@18.2.37)(react@18.2.0) devDependencies: '@testing-library/jest-dom': - specifier: ^6.9.1 + specifier: 6.9.1 version: 6.9.1 '@testing-library/react': specifier: ^16.1.0 diff --git a/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx b/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx new file mode 100644 index 00000000..6d2186aa --- /dev/null +++ b/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx @@ -0,0 +1,149 @@ +/** + * Step 7 集成测试:证明 ShotRenderPanel 真的挂载在 EP001 工作台里, + * 且选中/切换/取消选中都把正确的 production_shot_id 传给它。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import type * as CasApi from '../../../services/casWorkspaceApi' + +vi.mock('../../../services/casWorkspaceApi', async () => { + const actual = await vi.importActual('../../../services/casWorkspaceApi') + return { + ...actual, + fetchChapter: vi.fn(), + fetchShotBundles: vi.fn(), + fetchSubtitleFiles: vi.fn(), + fetchSubtitleText: vi.fn(), + fetchProductionJobs: vi.fn(), + fetchProductionJob: vi.fn(), + fetchProductionArtifacts: vi.fn(), + startShotRender: vi.fn(), + } +}) + +import * as api from '../../../services/casWorkspaceApi' +import Ep001Workspace from './Ep001Workspace' + +const CHAPTER = { + id: 'ch-1', + project_id: 'proj-1', + title: 'BTC Breaks Out', + summary: '', + storyboard_count: 2, + status: 'draft', +} + +function bundle(id: string, index: number, title: string) { + return { + shot: { id, chapter_id: 'ch-1', index, title, status: 'pending', script_excerpt: '' }, + detail: { id, duration: 3, camera_shot: 'MEDIUM', angle: 'EYE_LEVEL', movement: 'STATIC' }, + dialogLines: [], + } +} + +const JOB = { + id: 'job-1', + project_id: 'proj-1', + episode_id: 'CAS-EP001', + status: 'running', + current_stage: 'video_generation', + provider_mode: 'render', + render_task: null, + shots: [ + { id: 'pshot-1', source_shot_id: 'SC01', sequence: 1, status: 'pending' }, + { id: 'pshot-2', source_shot_id: 'SC02', sequence: 2, status: 'pending' }, + ], +} + +function renderWorkspace() { + return render( + + + } /> + + , + ) +} + +beforeEach(() => { + vi.mocked(api.fetchChapter).mockResolvedValue(CHAPTER as never) + vi.mocked(api.fetchShotBundles).mockResolvedValue([ + bundle('s1', 1, 'The premature toast'), + bundle('s2', 2, 'Confirmation, please'), + ] as never) + vi.mocked(api.fetchSubtitleFiles).mockResolvedValue([] as never) + vi.mocked(api.fetchProductionJobs).mockResolvedValue([JOB] as never) + vi.mocked(api.fetchProductionJob).mockResolvedValue(JOB as never) + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([] as never) +}) + +describe('Ep001Workspace → ShotRenderPanel mounting', () => { + it('requests production jobs using the route chapterId, not a guessed episode id', async () => { + renderWorkspace() + await waitFor(() => expect(api.fetchProductionJobs).toHaveBeenCalled()) + // ChapterRead 不含 episode_id;权威映射由后端按 chapter_id 从导入台账解析。 + expect(api.fetchProductionJobs).toHaveBeenCalledWith('proj-1', { chapterId: 'ch-1' }) + }) + + it('does not show the render panel until a shot is selected', async () => { + renderWorkspace() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + expect(screen.queryByTestId('render-panel')).not.toBeInTheDocument() + }) + + it('renders the panel for the selected shot with its production_shot_id', async () => { + renderWorkspace() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + fireEvent.click(screen.getAllByTestId('shot-row')[0]) + + await waitFor(() => expect(screen.getByTestId('render-panel')).toBeInTheDocument()) + // sequence 1 → pshot-1(不是 chapter id、不是 episode id、不是 job id) + await waitFor(() => expect(api.fetchProductionJob).toHaveBeenCalledWith('job-1')) + expect(screen.getByTestId('generate-video')).toBeInTheDocument() + }) + + it('switching shots swaps the panel to the new production shot', async () => { + renderWorkspace() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + + fireEvent.click(screen.getAllByTestId('shot-row')[0]) + await waitFor(() => expect(screen.getByTestId('render-panel')).toBeInTheDocument()) + + vi.mocked(api.startShotRender).mockResolvedValue({ + task_id: 't1', + status: 'pending', + is_terminal: false, + } as never) + fireEvent.click(screen.getByTestId('generate-video')) + await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith('job-1', 'pshot-1')) + + // 切换到第二个镜头(accordion 会关掉第一个) + fireEvent.click(screen.getAllByTestId('shot-row')[1]) + await waitFor(() => expect(screen.getByTestId('render-panel')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('generate-video')) + await waitFor(() => expect(api.startShotRender).toHaveBeenLastCalledWith('job-1', 'pshot-2')) + }) + + it('shows a clear notice when no production shot maps to the selection', async () => { + vi.mocked(api.fetchProductionJobs).mockResolvedValue([{ ...JOB, shots: [] }] as never) + renderWorkspace() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + fireEvent.click(screen.getAllByTestId('shot-row')[0]) + await waitFor(() => + expect(screen.getByTestId('render-panel-unavailable')).toBeInTheDocument(), + ) + expect(screen.queryByTestId('render-panel')).not.toBeInTheDocument() + }) + + it('shows the notice when the episode has no production job at all', async () => { + vi.mocked(api.fetchProductionJobs).mockResolvedValue([] as never) + renderWorkspace() + await waitFor(() => expect(screen.getByTestId('shot-list')).toBeInTheDocument()) + fireEvent.click(screen.getAllByTestId('shot-row')[0]) + await waitFor(() => + expect(screen.getByTestId('render-panel-unavailable')).toBeInTheDocument(), + ) + }) +}) diff --git a/front/src/pages/aiStudio/cas/Ep001Workspace.tsx b/front/src/pages/aiStudio/cas/Ep001Workspace.tsx index d911a0a2..0fa29c63 100644 --- a/front/src/pages/aiStudio/cas/Ep001Workspace.tsx +++ b/front/src/pages/aiStudio/cas/Ep001Workspace.tsx @@ -41,6 +41,13 @@ import { type SubtitleArtifact, type TaskStatusView, } from '../../../services/casWorkspaceApi' +import { + fetchProductionJobs, + findProductionShotId, + selectActiveProductionJob, + type ProductionJobSummary, +} from '../../../services/casWorkspaceApi' +import ShotRenderPanel from './ShotRenderPanel' import { parseWebVtt, type ParsedVtt } from './webvtt' const POLL_INTERVAL_MS = 2000 @@ -67,6 +74,10 @@ export default function Ep001Workspace() { const [preview, setPreview] = useState(null) const [artifactFromImport, setArtifactFromImport] = useState(null) + // Step 7:选中的镜头(按 index 标识)与其所属的 CAS 生产任务。 + const [selectedShotIndex, setSelectedShotIndex] = useState(null) + const [productionJob, setProductionJob] = useState(null) + const [task, setTask] = useState(null) const [taskReused, setTaskReused] = useState(false) const [importError, setImportError] = useState(null) @@ -126,6 +137,20 @@ export default function Ep001Workspace() { } }, [projectId, chapterId]) + // Step 7:定位本剧集的 CAS 生产任务(前端无法凭 chapterId 推出 job_id)。 + // 用路由里的 chapterId 定位剧集:ChapterRead 不含 episode_id, + // 权威的 章节→剧集 映射由后端从 cas_import_ledger 解析。 + const loadProductionJob = useCallback(async () => { + if (!projectId || !chapterId) return + try { + const jobs = await fetchProductionJobs(projectId, { chapterId }) + // 不依赖返回数组的位置:在前端再施加一次确定性选取规则。 + if (mountedRef.current) setProductionJob(selectActiveProductionJob(jobs)) + } catch { + if (mountedRef.current) setProductionJob(null) // 无生产任务不应让整页失败 + } + }, [projectId, chapterId]) + useEffect(() => { mountedRef.current = true void loadWorkspace() @@ -136,6 +161,10 @@ export default function Ep001Workspace() { } }, [loadWorkspace, loadSubtitle, stopPolling]) + useEffect(() => { + void loadProductionJob() + }, [loadProductionJob]) + // 有界轮询:仅在非终态时继续,卸载/终态即停止。 const pollTask = useCallback( async (taskId: string, attempt: number) => { @@ -284,6 +313,12 @@ export default function Ep001Workspace() { ) : ( { + const activeKey = Array.isArray(key) ? key[0] : key + const found = shots.find(({ shot }) => shot.id === activeKey) + setSelectedShotIndex(found ? (found.shot.index ?? null) : null) + }} items={shots.map(({ shot, detail, dialogLines }) => ({ key: shot.id, label: ( @@ -338,6 +373,29 @@ export default function Ep001Workspace() { )} + {/* --- Step 7:选中镜头的渲染面板 --- */} + {(() => { + const productionShotId = findProductionShotId(productionJob, selectedShotIndex ?? undefined) + if (selectedShotIndex === null) return null + if (!productionJob || !productionShotId) { + return ( + + ) + } + return ( +
+ +
+ ) + })()} + {/* --- 字幕产物 --- */} {subtitleLoading ? ( diff --git a/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx b/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx new file mode 100644 index 00000000..db191c6e --- /dev/null +++ b/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' + +import type * as CasApi from '../../../services/casWorkspaceApi' + +vi.mock('../../../services/casWorkspaceApi', async () => { + const actual = await vi.importActual('../../../services/casWorkspaceApi') + return { + ...actual, + fetchProductionJob: vi.fn(), + fetchProductionArtifacts: vi.fn(), + startShotRender: vi.fn(), + } +}) + +import * as api from '../../../services/casWorkspaceApi' +import ShotRenderPanel from './ShotRenderPanel' + +const JOB = 'job-1' +const SHOT = 'pshot-1' + +function job(renderTask: Partial | null) { + return { + id: JOB, + project_id: 'p', + episode_id: 'CAS-EP001', + status: 'running', + current_stage: 'video_generation', + provider_mode: 'render', + render_task: renderTask + ? { + task_id: 't1', + status: 'running', + progress: 20, + stage_message: 'Submitted to render provider', + provider_task_id: null, + error_reason: null, + attempt: 1, + is_terminal: false, + ...renderTask, + } + : null, + } +} + +function artifact(over: Partial = {}) { + return { + id: 'a1', + production_shot_id: SHOT, + artifact_type: 'video', + stage: 'video_generation', + provider: 'comfyui', + provider_model: '', + file_path: 'cas/renders/job-1/pshot-1/v.mp4', + mime_type: 'video/mp4', + checksum: '', + file_id: 'file-1', + size_bytes: null, + download_url: '/api/v1/studio/files/file-1/download', + provider_job_id: 'prompt-1', + attempt: 1, + ...over, + } +} + +function renderPanel(shotId = SHOT) { + return render() +} + +beforeEach(() => { + vi.mocked(api.fetchProductionJob).mockResolvedValue(job(null) as never) + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([] as never) +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('ShotRenderPanel', () => { + it('recovers state from the backend on initial load', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue( + job({ status: 'succeeded', progress: 100, is_terminal: true }) as never, + ) + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([artifact()] as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('render-status')).toBeInTheDocument()) + expect(screen.getByTestId('render-status-tag').textContent).toContain('succeeded') + expect(screen.getByTestId('artifact-list')).toBeInTheDocument() + expect(api.fetchProductionJob).toHaveBeenCalledWith(JOB) + }) + + it('shows the empty state when no attempt exists', async () => { + renderPanel() + await waitFor(() => expect(screen.getByTestId('render-empty')).toBeInTheDocument()) + expect(screen.getByTestId('artifacts-empty')).toBeInTheDocument() + }) + + it('only shows artifacts belonging to the selected shot', async () => { + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([ + artifact(), + artifact({ id: 'a2', production_shot_id: 'other-shot' }), + ] as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('artifact-list')).toBeInTheDocument()) + expect(screen.getAllByTestId('artifact-item')).toHaveLength(1) + }) + + it('starts a render and disables the button while active', async () => { + vi.mocked(api.startShotRender).mockResolvedValue({ + task_id: 't9', + status: 'pending', + progress: 0, + stage_message: 'Queued', + is_terminal: false, + attempt: 1, + } as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('generate-video')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('generate-video')) + await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith(JOB, SHOT)) + await waitFor(() => + expect(screen.getByTestId('generate-video').closest('button')).toBeDisabled(), + ) + }) + + it('renders determinate progress when a number is provided', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue(job({ progress: 80 }) as never) + renderPanel() + await waitFor(() => + expect(screen.getByTestId('render-progress-determinate')).toBeInTheDocument(), + ) + }) + + it('renders indeterminate progress when progress is null', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue(job({ progress: null }) as never) + renderPanel() + await waitFor(() => + expect(screen.getByTestId('render-progress-indeterminate')).toBeInTheDocument(), + ) + }) + + it('shows provider-processing between submit and download', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue(job({ progress: 20 }) as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('provider-processing')).toBeInTheDocument()) + }) + + it('displays the stage message', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue( + job({ stage_message: 'Downloading generated video', progress: 80 }) as never, + ) + renderPanel() + await waitFor(() => + expect(screen.getByTestId('render-stage').textContent).toBe('Downloading generated video'), + ) + }) + + it('shows only the safe failure reason and offers retry', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue( + job({ + status: 'failed', + is_terminal: true, + error_reason: 'provider: The render provider reported an execution failure.', + }) as never, + ) + renderPanel() + await waitFor(() => expect(screen.getByTestId('render-failure')).toBeInTheDocument()) + const text = screen.getByTestId('render-failure').textContent || '' + expect(text).not.toContain('Traceback') + expect(text).not.toContain('sk-') + expect(screen.getByTestId('retry-render')).toBeInTheDocument() + }) + + it('keeps earlier artifacts visible after starting a retry', async () => { + vi.mocked(api.fetchProductionJob).mockResolvedValue( + job({ status: 'failed', is_terminal: true, error_reason: 'provider: failed' }) as never, + ) + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([artifact()] as never) + vi.mocked(api.startShotRender).mockResolvedValue({ + task_id: 't2', + status: 'pending', + is_terminal: false, + attempt: 2, + } as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('artifact-list')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('retry-render')) + await waitFor(() => expect(api.startShotRender).toHaveBeenCalled()) + expect(screen.getAllByTestId('artifact-item')).toHaveLength(1) + }) + + it('plays the artifact via download_url and never from storage_key', async () => { + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([artifact()] as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('artifact-video')).toBeInTheDocument()) + const src = screen.getByTestId('artifact-video').getAttribute('src') || '' + expect(src).toBe('/api/v1/studio/files/file-1/download') + expect(src).not.toContain('cas/renders') + }) + + it('renders safely when size_bytes is null', async () => { + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([artifact()] as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('artifact-size')).toBeInTheDocument()) + expect(screen.getByTestId('artifact-size').textContent).toBe('大小未知') + }) + + it('shows an unplayable notice when download_url is absent', async () => { + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([ + artifact({ download_url: null, file_id: null }), + ] as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('artifact-unplayable')).toBeInTheDocument()) + }) + + it('polls while non-terminal and stops at a terminal state', async () => { + vi.useFakeTimers() + vi.mocked(api.fetchProductionJob) + .mockResolvedValueOnce(job({ progress: 20 }) as never) + .mockResolvedValue(job({ status: 'succeeded', progress: 100, is_terminal: true }) as never) + + renderPanel() + await vi.advanceTimersByTimeAsync(0) + expect(api.fetchProductionJob).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(3000) + expect(api.fetchProductionJob).toHaveBeenCalledTimes(2) + + const callsAtTerminal = vi.mocked(api.fetchProductionJob).mock.calls.length + await vi.advanceTimersByTimeAsync(30000) + expect(vi.mocked(api.fetchProductionJob).mock.calls.length).toBe(callsAtTerminal) + }) + + it('never issues overlapping poll requests', async () => { + vi.useFakeTimers() + let release: (v: unknown) => void = () => undefined + const pending = new Promise((resolve) => { + release = resolve + }) + vi.mocked(api.fetchProductionJob).mockImplementation( + () => pending.then(() => job({ progress: 20 })) as never, + ) + renderPanel() + await vi.advanceTimersByTimeAsync(0) + // 首个请求仍未完成时推进多个轮询周期 + await vi.advanceTimersByTimeAsync(30000) + expect(vi.mocked(api.fetchProductionJob).mock.calls.length).toBe(1) + release(null) + }) + + it('stops polling on unmount', async () => { + vi.useFakeTimers() + vi.mocked(api.fetchProductionJob).mockResolvedValue(job({ progress: 20 }) as never) + const { unmount } = renderPanel() + await vi.advanceTimersByTimeAsync(0) + const before = vi.mocked(api.fetchProductionJob).mock.calls.length + unmount() + await vi.advanceTimersByTimeAsync(30000) + expect(vi.mocked(api.fetchProductionJob).mock.calls.length).toBe(before) + }) + + it('resets and ignores stale responses when the selected shot changes', async () => { + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([artifact()] as never) + const { rerender } = renderPanel() + await waitFor(() => expect(screen.getByTestId('artifact-list')).toBeInTheDocument()) + + // 切到另一个镜头:旧镜头的产物不得残留 + vi.mocked(api.fetchProductionArtifacts).mockResolvedValue([ + artifact({ id: 'a9', production_shot_id: 'pshot-2' }), + ] as never) + rerender() + await waitFor(() => expect(screen.getAllByTestId('artifact-item')).toHaveLength(1)) + expect(within(screen.getByTestId('artifact-list')).getByTestId('artifact-video')).toBeInTheDocument() + }) + + it('shows a recoverable API-error state without a tight retry loop', async () => { + vi.useFakeTimers() + vi.mocked(api.fetchProductionJob).mockRejectedValue(new Error('backend down') as never) + renderPanel() + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => expect(screen.getByTestId('render-api-error')).toBeInTheDocument()) + const calls = vi.mocked(api.fetchProductionJob).mock.calls.length + await vi.advanceTimersByTimeAsync(30000) + expect(vi.mocked(api.fetchProductionJob).mock.calls.length).toBe(calls) + }) +}) diff --git a/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx b/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx new file mode 100644 index 00000000..6a1e49c1 --- /dev/null +++ b/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx @@ -0,0 +1,262 @@ +/** + * 单镜头渲染面板(Step 7)。 + * + * 纪律: + * - 状态**全部来自后端**:刷新后靠 fetchProductionJob / fetchProductionArtifacts 恢复; + * - 轮询用「上一次请求完成后再排下一次」的递归 setTimeout,并配合 in-flight 守卫, + * 因此请求不可能重叠;终态、卸载、切换镜头都会停止; + * - 播放只用后端给的 download_url,绝不由 storage_key 拼地址。 + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Alert, Button, Card, Descriptions, Empty, Progress, Space, Spin, Tag, Typography } from 'antd' + +import { + RENDER_POLL_INTERVAL_MS, + artifactsForShot, + fetchProductionArtifacts, + fetchProductionJob, + startShotRender, + type RenderArtifactView, + type RenderTaskView, +} from '../../../services/casWorkspaceApi' + +const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']) + +/** 供应商处理中:有任务在跑但拿不到确定进度。 */ +function isProviderProcessing(task: RenderTaskView | null): boolean { + if (!task || TERMINAL.has(task.status)) return false + return (task.progress ?? 0) >= 20 && (task.progress ?? 0) < 80 +} + +function statusColor(status: string): string { + if (status === 'succeeded') return 'green' + if (status === 'failed') return 'red' + if (status === 'cancelled') return 'default' + return 'blue' +} + +export interface ShotRenderPanelProps { + jobId: string + productionShotId: string +} + +export default function ShotRenderPanel({ jobId, productionShotId }: ShotRenderPanelProps) { + const [task, setTask] = useState(null) + const [artifacts, setArtifacts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + const mountedRef = useRef(true) + const timerRef = useRef | null>(null) + const inFlightRef = useRef(false) + /** 请求代次:切换镜头后旧响应会被丢弃,不能覆盖当前状态。 */ + const generationRef = useRef(0) + + const stopPolling = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + /** 拉取一次后端状态;返回是否已到终态。 */ + const loadOnce = useCallback( + async (generation: number): Promise => { + if (inFlightRef.current) return false // 上一次仍在进行 → 不并发 + inFlightRef.current = true + try { + const [job, allArtifacts] = await Promise.all([ + fetchProductionJob(jobId), + fetchProductionArtifacts(jobId), + ]) + // 卸载、或已切换到别的镜头 → 丢弃这次响应 + if (!mountedRef.current || generation !== generationRef.current) return true + const nextTask = job.render_task ?? null + setTask(nextTask) + setArtifacts(artifactsForShot(allArtifacts, productionShotId)) + setError(null) + return !nextTask || nextTask.is_terminal + } catch (err) { + if (mountedRef.current && generation === generationRef.current) { + setError((err as Error)?.message || '读取渲染状态失败') + } + return true // 出错即停止,避免不受控的紧密重试循环 + } finally { + inFlightRef.current = false + if (mountedRef.current && generation === generationRef.current) setLoading(false) + } + }, + [jobId, productionShotId], + ) + + const scheduleNext = useCallback( + (generation: number) => { + stopPolling() + timerRef.current = setTimeout(async () => { + const done = await loadOnce(generation) + if (!done && mountedRef.current && generation === generationRef.current) { + scheduleNext(generation) + } + }, RENDER_POLL_INTERVAL_MS) + }, + [loadOnce, stopPolling], + ) + + // 初次加载 / 切换镜头:重置代次并从后端恢复状态 + useEffect(() => { + mountedRef.current = true + generationRef.current += 1 + const generation = generationRef.current + setLoading(true) + setTask(null) + setArtifacts([]) + setError(null) + stopPolling() + + void (async () => { + const done = await loadOnce(generation) + if (!done && mountedRef.current && generation === generationRef.current) { + scheduleNext(generation) + } + })() + + return () => { + mountedRef.current = false + generationRef.current += 1 // 使在途响应失效 + stopPolling() + } + }, [jobId, productionShotId, loadOnce, scheduleNext, stopPolling]) + + const handleGenerate = useCallback(async () => { + setSubmitting(true) + setError(null) + try { + const accepted = await startShotRender(jobId, productionShotId) + if (!mountedRef.current) return + setTask(accepted) + const generation = generationRef.current + if (!accepted.is_terminal) scheduleNext(generation) + } catch (err) { + if (mountedRef.current) setError((err as Error)?.message || '发起渲染失败') + } finally { + if (mountedRef.current) setSubmitting(false) + } + }, [jobId, productionShotId, scheduleNext]) + + const active = !!task && !task.is_terminal + const failed = task?.status === 'failed' + + if (loading) { + return ( + + +
+ + + ) + } + + return ( + + {error && ( + + )} + + + + {failed && ( + + )} + + + {!task ? ( + + ) : ( + + + + {task.status} + + + + {task.stage_message || '—'} + + + {typeof task.progress === 'number' ? ( + + ) : ( + 处理中… + )} + + {isProviderProcessing(task) && ( + + 供应商处理中 + + )} + {task.attempt ?? '—'} + {task.provider_task_id && ( + + {task.provider_task_id} + + )} + {failed && ( + + {task.error_reason || '渲染失败'} + + )} + + )} + + + 产物 + + {artifacts.length === 0 ? ( + + ) : ( +
    + {artifacts.map((artifact) => ( +
  • +
    + 尝试 {artifact.attempt ?? '—'} · {artifact.mime_type} ·{' '} + + {typeof artifact.size_bytes === 'number' ? `${artifact.size_bytes} B` : '大小未知'} + +
    + {artifact.download_url ? ( + + ) : ( + 该产物暂无可用播放地址 + )} +
  • + ))} +
+ )} +
+ ) +} diff --git a/front/src/services/casWorkspaceApi.ts b/front/src/services/casWorkspaceApi.ts index 039bb2fb..1c53c685 100644 --- a/front/src/services/casWorkspaceApi.ts +++ b/front/src/services/casWorkspaceApi.ts @@ -26,10 +26,61 @@ import type { SubtitleArtifact, } from './generated' import { buildFileDownloadUrl } from '../pages/aiStudio/assets/utils' +import { get, post } from './http' /** 任务终态集合:到达即停止轮询。 */ export const TERMINAL_TASK_STATUSES = ['succeeded', 'failed', 'cancelled'] as const +/** + * Step 7 渲染相关类型。 + * + * 这些字段是本步骤新增的后端契约(ProductionJobView.render_task 与 + * ProductionArtifactView 的可选字段)。在下一次 `pnpm run openapi:update` + * 之前生成客户端尚不认识它们,因此此处以手写类型对齐后端 schema, + * 而不是手工编辑生成代码。 + */ +export interface RenderTaskView { + task_id: string + status: string + progress?: number | null + stage_message?: string | null + provider_task_id?: string | null + error_reason?: string | null + attempt?: number | null + is_terminal: boolean +} + +export interface RenderArtifactView { + id: string + production_shot_id?: string | null + artifact_type: string + stage: string + provider: string + provider_model: string + file_path: string + mime_type: string + checksum: string + file_id?: string | null + size_bytes?: number | null + download_url?: string | null + provider_job_id?: string | null + attempt?: number | null +} + +export interface ProductionJobSummary { + id: string + project_id: string + episode_id: string + status: string + current_stage: string + provider_mode: string + render_task?: RenderTaskView | null + shots?: Array<{ id: string; source_shot_id: string; sequence: number; status: string }> +} + +/** 轮询间隔(毫秒)。 */ +export const RENDER_POLL_INTERVAL_MS = 3000 + export type TaskStatusValue = | 'pending' | 'running' @@ -176,6 +227,108 @@ export async function fetchTaskResult(taskId: string): Promise(res: unknown): T { + return ((res as { data?: T })?.data ?? res) as T +} + +/** + * 按项目/剧集列出生产任务(最新在前)。 + * + * 工作台必须据此定位 job_id:Jellyfish 的 Chapter/Shot 与 CAS 的 + * CasProductionJob/CasProductionShot 是不同实体,前端无法凭空得到 job_id。 + */ +export async function fetchProductionJobs( + projectId: string, + filter: { episodeId?: string; chapterId?: string } = {}, +): Promise { + const query = new URLSearchParams({ project_id: projectId }) + if (filter.episodeId) query.set('episode_id', filter.episodeId) + // Jellyfish 的 ChapterRead 不含 episode_id(Chapter 不建模剧集)。 + // 权威映射在 cas_import_ledger 里,由后端按 chapter_id 解析。 + if (filter.chapterId) query.set('chapter_id', filter.chapterId) + const data = unwrap( + await get(`${CAS_BASE}/production/jobs?${query.toString()}`), + ) + return Array.isArray(data) ? data : [] +} + +/** + * 从多个生产任务中确定性地选出工作台要用的那一个。 + * + * **不依赖 API/数据库的返回顺序**:即使后端顺序变化,这里也会重新施加同一条规则 + * —— `created_at` 降序,并列时以 `id` 降序作次级键,构成稳定全序。 + * 因此更旧或无关的任务不会被选中(无关剧集已由 episode_id 过滤在服务端排除)。 + * + * 局限:`cas_production_jobs` 没有自增列,`created_at` 在同一秒并列时, + * 次级键 id 是随机 UUID —— 结果稳定可复现,但并非语义上的「最新」。 + */ +export function selectActiveProductionJob( + jobs: ProductionJobSummary[], +): ProductionJobSummary | null { + if (!jobs.length) return null + const sorted = [...jobs].sort((a, b) => { + const createdA = (a as { created_at?: string }).created_at ?? '' + const createdB = (b as { created_at?: string }).created_at ?? '' + if (createdA !== createdB) return createdA < createdB ? 1 : -1 + return a.id < b.id ? 1 : -1 + }) + return sorted[0] +} + +/** + * 把工作台里选中的 Jellyfish 镜头映射到 CAS 生产镜头。 + * + * 依据 sequence/index:导入器把 EpisodePackage 的 shot sequence 写入 + * Jellyfish ``Shot.index``,同时写入 ``CasProductionShot.sequence``, + * 因此两者以序号对齐。ShotRead 本身不携带 source_shot_id。 + */ +export function findProductionShotId( + job: ProductionJobSummary | null, + shotIndex: number | undefined, +): string | null { + if (!job || typeof shotIndex !== 'number') return null + const match = (job.shots ?? []).find((s) => s.sequence === shotIndex) + return match?.id ?? null +} + +/** 取某个生产任务的完整状态(含 render_task 投影)。 */ +export async function fetchProductionJob(jobId: string): Promise { + return unwrap(await get(`${CAS_BASE}/production/jobs/${jobId}`)) +} + +/** 取某个生产任务的全部产物。 */ +export async function fetchProductionArtifacts(jobId: string): Promise { + const data = unwrap( + await get(`${CAS_BASE}/production/jobs/${jobId}/artifacts`), + ) + return Array.isArray(data) ? data : [] +} + +/** 为单个生产镜头发起渲染;后端入队后立即返回。 */ +export async function startShotRender( + jobId: string, + productionShotId: string, +): Promise { + return unwrap( + await post(`${CAS_BASE}/production/jobs/${jobId}/shots/${productionShotId}/render`, {}), + ) +} + +/** 只保留属于该生产镜头的产物,避免展示其它镜头的结果。 */ +export function artifactsForShot( + artifacts: RenderArtifactView[], + productionShotId: string, +): RenderArtifactView[] { + return artifacts.filter( + (a) => a.production_shot_id === productionShotId && a.artifact_type === 'video', + ) +} + export type { CasImportTaskAccepted, ChapterRead, From 99432040f39f1f8e71460357f4c1147ad81abbc9 Mon Sep 17 00:00:00 2001 From: shiuyu Date: Sun, 9 Aug 2026 17:29:23 +0800 Subject: [PATCH 5/5] [feat] Add CAS production render pipeline --- .dockerignore | 3 + backend/.env.example | 20 +++ backend/app/config.py | 8 ++ .../app/core/contracts/video_generation.py | 20 +++ .../app/core/integrations/comfyui/__init__.py | 4 + .../app/core/integrations/comfyui/workflow.py | 37 +++++ .../app/core/tasks/video_generation_tasks.py | 32 ++++- .../crypto_animal_studio/api/production.py | 38 ++++-- .../application/render_request.py | 13 ++ .../application/render_tasks.py | 32 ++++- backend/tests/test_cas_render_api.py | 43 ++++++ backend/tests/test_cas_render_e2e.py | 61 +++++++++ backend/tests/test_cas_render_request.py | 26 ++++ backend/tests/test_comfyui_provider.py | 129 ++++++++++++++++++ .../comfyui/cas_sd15_image_test.api.json | 107 +++++++++++++++ .../comfyui/cas_sd15_image_test_mapping.json | 11 ++ deploy/compose/.env.example | 20 +++ deploy/compose/docker-compose.yml | 10 ++ .../cas/Ep001Workspace.render.test.tsx | 4 +- .../aiStudio/cas/ShotRenderPanel.test.tsx | 32 ++++- .../pages/aiStudio/cas/ShotRenderPanel.tsx | 42 +++++- front/src/services/casWorkspaceApi.ts | 17 ++- 22 files changed, 685 insertions(+), 24 deletions(-) create mode 100644 .dockerignore create mode 100644 backend/workflows/comfyui/cas_sd15_image_test.api.json create mode 100644 backend/workflows/comfyui/cas_sd15_image_test_mapping.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e46d38ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/node_modules +**/.pnpm-store +**/dist \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 8a549384..07461ab4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -49,3 +49,23 @@ API_V1_PREFIX=/api/v1 # S3_BASE_PATH=jellyfish/dev # 若有 CDN/自定义域名,可配置成 https://cdn.example.com # S3_PUBLIC_BASE_URL= + +# --------------------------------------------------------------------------- +# Crypto Animal Studio — single-shot render (Step 7) +# --------------------------------------------------------------------------- +# Render provider: comfyui | volcengine | openai +CAS_RENDER_PROVIDER=comfyui +# Self-hosted ComfyUI instance. No default: rendering fails clearly if unset. +# From inside Docker use host.docker.internal to reach a host-side ComfyUI. +CAS_COMFYUI_BASE_URL=http://host.docker.internal:8188 +# Path to the workflow input/output mapping JSON. +# The workflow itself must be exported in ComfyUI API format. +# The mapping MUST map "width" and "height", otherwise a render fails fast. +CAS_COMFYUI_WORKFLOW_MAPPING=/app/workflows/cas_txt2video.mapping.json +# Poll interval and per-render timeout (seconds). +CAS_RENDER_POLL_INTERVAL_S=3.0 +CAS_RENDER_TIMEOUT_S=1800.0 +# Preview profile resolution. 432x768 is exact 9:16 and both are multiples of 8; +# roughly 16% of the 1080x1920 final profile, suitable for Intel Iris Xe. +CAS_RENDER_PREVIEW_WIDTH=432 +CAS_RENDER_PREVIEW_HEIGHT=768 diff --git a/backend/app/config.py b/backend/app/config.py index 8b81dbd2..523e802b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -74,6 +74,14 @@ def cors_origins_list(self) -> list[str]: #: 单次渲染超时(秒)。视频生成通常远慢于图像,缺省给足余量。 cas_render_timeout_s: float = 1800.0 + # 分辨率档位:预览档用于低成本试跑(如 Intel Iris Xe 等核显), + # 成片档保持 EP001 的 1080×1920 输出规格。两者都必须是 8 的倍数。 + #: 预览档宽度。 + cas_render_preview_width: int = 432 + #: 预览档高度。432×768 = 精确 9:16(0.5625),两者都是 8 的倍数, + #: 像素量约为成片档 1080×1920 的 16%,适合 Intel Iris Xe 等核显试跑。 + cas_render_preview_height: int = 768 + def model_post_init(self, __context: object) -> None: if not self.celery_broker_url or not str(self.celery_broker_url).strip(): password_part = f":{self.redis_password}@" if self.redis_password else "" diff --git a/backend/app/core/contracts/video_generation.py b/backend/app/core/contracts/video_generation.py index 9a2fb4bd..2857c660 100644 --- a/backend/app/core/contracts/video_generation.py +++ b/backend/app/core/contracts/video_generation.py @@ -41,6 +41,26 @@ class VideoGenerationInput(BaseModel): ) watermark: Optional[bool] = Field(None, description="是否包含水印,供应商/模型可能有差异") + #: 可选的显式渲染分辨率。省略时由 ratio 推导(既有行为完全不变)。 + #: 用途:预览档(低分辨率试跑)与成片档(全分辨率)共用同一条渲染路径。 + width: Optional[int] = Field(None, gt=0, description="渲染宽度(像素);须与 height 同时提供") + height: Optional[int] = Field(None, gt=0, description="渲染高度(像素);须与 width 同时提供") + + @model_validator(mode="after") + def check_explicit_dimensions(self) -> "VideoGenerationInput": + """width/height 必须成对出现,且为 8 的倍数。 + + 成对要求:只给一个无法推导另一个,静默补齐会产生非预期的画面比例。 + 8 的倍数:扩散模型的潜空间按 8 下采样,非 8 倍数会在供应商侧报错或被 + 悄悄取整;在此处提前拒绝,错误信息比 ComfyUI 的节点报错清晰得多。 + """ + if (self.width is None) != (self.height is None): + raise ValueError("width and height must be provided together or both omitted") + for name, value in (("width", self.width), ("height", self.height)): + if value is not None and value % 8 != 0: + raise ValueError(f"{name} must be a multiple of 8, got {value}") + return self + @model_validator(mode="after") def require_prompt_or_any_reference(self) -> "VideoGenerationInput": has_prompt = bool((self.prompt or "").strip()) diff --git a/backend/app/core/integrations/comfyui/__init__.py b/backend/app/core/integrations/comfyui/__init__.py index 84798911..fb336e23 100644 --- a/backend/app/core/integrations/comfyui/__init__.py +++ b/backend/app/core/integrations/comfyui/__init__.py @@ -8,16 +8,20 @@ read_execution_status, ) from app.core.integrations.comfyui.workflow import ( + REQUIRED_RENDER_INPUTS, WorkflowConfigError, WorkflowMapping, apply_inputs, load_mapping, + require_render_inputs, ) __all__ = [ + "REQUIRED_RENDER_INPUTS", "ComfyUIError", "ComfyUIVideoApiAdapter", "WorkflowConfigError", + "require_render_inputs", "WorkflowMapping", "apply_inputs", "extract_video_output", diff --git a/backend/app/core/integrations/comfyui/workflow.py b/backend/app/core/integrations/comfyui/workflow.py index 1eace425..b00cb16c 100644 --- a/backend/app/core/integrations/comfyui/workflow.py +++ b/backend/app/core/integrations/comfyui/workflow.py @@ -140,6 +140,43 @@ def load_mapping(mapping_path: str | Path, *, base_dir: str | Path | None = None return WorkflowMapping(workflow=workflow, inputs=normalized, output_node=output_node) +#: 渲染必须能够控制的输入。缺少这些映射时工作流会用它自带的内置尺寸出图, +#: 导致「预览档看似成功、实际仍是成片分辨率」这种静默失败。 +REQUIRED_RENDER_INPUTS: tuple[str, ...] = ("width", "height") + + +def require_render_inputs( + mapping: WorkflowMapping, required: tuple[str, ...] = REQUIRED_RENDER_INPUTS +) -> None: + """确认映射确实能把 ``required`` 写入有效的 workflow 节点输入路径。 + + 不只看键是否存在(dictionary truthiness):还要确认映射目标解析得出节点与 + 输入名,且该节点确实存在于工作流中。任何一项不满足都抛 + ``WorkflowConfigError``,调用方据此让任务 failed,绝不提交 prompt。 + + 异常: + WorkflowConfigError:错误信息明确列出缺少/不可用的键。 + """ + missing: list[str] = [] + for key in required: + target = mapping.inputs.get(key) + if not isinstance(target, str) or not target.strip(): + missing.append(key) + continue + try: + node_id, field = _split_target(target) + except WorkflowConfigError: + missing.append(key) + continue + node = mapping.workflow.get(node_id) + if not isinstance(node, dict) or not field: + missing.append(key) + if missing: + raise WorkflowConfigError( + "ComfyUI workflow mapping is missing required inputs: " + ", ".join(sorted(missing)) + ) + + def apply_inputs(mapping: WorkflowMapping, values: dict[str, Any]) -> dict[str, Any]: """把 ``values`` 注入工作流副本并返回。 diff --git a/backend/app/core/tasks/video_generation_tasks.py b/backend/app/core/tasks/video_generation_tasks.py index 5ecab88e..fee606a4 100644 --- a/backend/app/core/tasks/video_generation_tasks.py +++ b/backend/app/core/tasks/video_generation_tasks.py @@ -283,7 +283,12 @@ def _build_workflow_values(self) -> dict[str, Any]: """把统一的 VideoGenerationInput 映射为工作流输入值。""" from app.core.integrations.video_capabilities import ALLOWED_RATIOS - width, height = _dimensions_for_ratio(self._input.ratio) + # 显式尺寸优先(预览档),否则按 ratio 推导(既有行为)。 + # 契约层已保证 width/height 要么同时存在、要么同时为 None。 + if self._input.width is not None and self._input.height is not None: + width, height = self._input.width, self._input.height + else: + width, height = _dimensions_for_ratio(self._input.ratio) values: dict[str, Any] = { "positive_prompt": (self._input.prompt or "").strip(), "width": width, @@ -300,9 +305,30 @@ def _build_workflow_values(self) -> dict[str, Any]: return values async def _create_task(self) -> None: - from app.core.integrations.comfyui import apply_inputs + from app.core.integrations.comfyui import ( + WorkflowConfigError, + apply_inputs, + require_render_inputs, + ) + + # fail-fast:映射必须真的能控制 width/height,否则工作流会用内置尺寸出图, + # 造成「预览档显示成功、实际仍是成片分辨率」的静默失败。 + # 在提交之前校验 —— 校验不过就不会调用 submit_prompt。 + require_render_inputs(self._mapping) + + values = self._build_workflow_values() + prompt = apply_inputs(self._mapping, values) + + # 注入后复核:确认目标节点确实拿到了期望的尺寸,而不只是「映射看起来存在」。 + for key in ("width", "height"): + node_id, field = self._mapping.inputs[key].split(".", 1) + actual = (prompt.get(node_id) or {}).get("inputs", {}).get(field) + if actual != values[key]: + raise WorkflowConfigError( + f"ComfyUI workflow mapping failed to apply {key}: " + f"expected {values[key]}, node {node_id}.{field} holds {actual!r}" + ) - prompt = apply_inputs(self._mapping, self._build_workflow_values()) self._provider_task_id = await self._adapter.submit_prompt( cfg=self._cfg, prompt=prompt, diff --git a/backend/app/crypto_animal_studio/api/production.py b/backend/app/crypto_animal_studio/api/production.py index e488df40..3e8cee33 100644 --- a/backend/app/crypto_animal_studio/api/production.py +++ b/backend/app/crypto_animal_studio/api/production.py @@ -6,6 +6,8 @@ from __future__ import annotations +from typing import Literal + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -169,7 +171,6 @@ async def list_production_artifacts(job_id: str, db: AsyncSession = Depends(get_ # Step 7:统一经 build_artifact_view 投影,补上 file_id / size / download_url 等可选字段。 return success_response(data=[build_artifact_view(a) for a in rows]) - @router.post( "/jobs/{job_id}/shots/{production_shot_id}/render", response_model=ApiResponse[RenderTaskView], @@ -177,18 +178,25 @@ async def list_production_artifacts(job_id: str, db: AsyncSession = Depends(get_ async def start_shot_render( job_id: str, production_shot_id: str, + profile: Literal["preview", "final"] = "final", db: AsyncSession = Depends(get_db), ) -> ApiResponse[RenderTaskView]: - """为单个生产镜头发起一次真实渲染尝试(入队后立即返回)。 + """Start a render task for one production shot. - 既有 Step 6 端点无法表达「渲染某一个镜头」,故新增本路由。 - 实际执行走既有任务中心 + Celery;本路由只登记与入队。 + ``profile``: + - ``final`` (default): dimensions derived from ratio (1080x1920). Behaviour is + identical to before this parameter existed. + - ``preview``: uses the configured low-resolution profile (default 544x960) for + low-power GPUs. The pixel values come from backend settings, never from the + caller, so clients cannot dictate render specs. """ job = await db.get(CasProductionJob, job_id) if job is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=f"production job not found: {job_id}" + status_code=status.HTTP_404_NOT_FOUND, + detail=f"production job not found: {job_id}", ) + shot = await db.get(CasProductionShot, production_shot_id) if shot is None or shot.job_id != job_id: raise HTTPException( @@ -196,18 +204,26 @@ async def start_shot_render( detail=f"production shot not found in job: {production_shot_id}", ) - active = await find_active_render_task(db, production_shot_id=production_shot_id) + active = await find_active_render_task( + db, + production_shot_id=production_shot_id, + ) if active is not None: - # 已有进行中的尝试:幂等返回,不重复入队。 return success_response(data=build_render_task_view(active)) - # 提示词只在 application 层组装:这里只把镜头已持久化的字段作为上下文传入。 render_request = build_render_request( shot, - context={"scene": shot.image_prompt or "", "action": shot.video_prompt or ""}, + context={ + "scene": shot.image_prompt or "", + "action": shot.video_prompt or "", + }, ratio="9:16", negative_prompt=shot.negative_prompt or None, + # preview 档走配置的低分辨率;final 档传 None,由 ratio 推导(既有行为)。 + width=settings.cas_render_preview_width if profile == "preview" else None, + height=settings.cas_render_preview_height if profile == "preview" else None, ) + task_row, _attempt = await create_shot_render_task( db, job=job, @@ -218,10 +234,10 @@ async def start_shot_render( poll_interval_s=settings.cas_render_poll_interval_s, timeout_s=settings.cas_render_timeout_s, ) - # 任务行必须先可见,worker 才能按 id 取到它。 + await db.commit() - from app.tasks.execute_task import enqueue_task_execution # 延迟导入,避免导入环 + from app.tasks.execute_task import enqueue_task_execution enqueue_task_execution(task_row.id) return success_response(data=build_render_task_view(task_row)) diff --git a/backend/app/crypto_animal_studio/application/render_request.py b/backend/app/crypto_animal_studio/application/render_request.py index 63be79bd..b1aadb2e 100644 --- a/backend/app/crypto_animal_studio/application/render_request.py +++ b/backend/app/crypto_animal_studio/application/render_request.py @@ -64,6 +64,9 @@ class RenderRequest: seconds: int seed: int | None snapshot: dict[str, Any] = field(default_factory=dict) + #: 可选的显式分辨率(预览档)。省略时由 ratio 推导(成片档)。 + width: int | None = None + height: int | None = None def to_video_input(self) -> VideoGenerationInput: """转为共享的供应商中立契约。""" @@ -72,6 +75,8 @@ def to_video_input(self) -> VideoGenerationInput: ratio=self.ratio, # type: ignore[arg-type] seconds=self.seconds, seed=self.seed, + width=self.width, + height=self.height, ) @@ -100,6 +105,8 @@ def build_render_request( ratio: str = "9:16", seed: int | None = None, negative_prompt: str | None = None, + width: int | None = None, + height: int | None = None, ) -> RenderRequest: """由生产镜头与上下文构造确定性渲染请求。 @@ -139,6 +146,10 @@ def build_render_request( "seed": seed, "sections": dict(ordered), "negative_prompt": negative, + # 分辨率纳入快照:同一提示词在预览档与成片档下的产出不同, + # 重试诊断时必须能区分两者。 + "width": width, + "height": height, "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), } @@ -151,6 +162,8 @@ def build_render_request( seconds=seconds, seed=seed, snapshot=snapshot, + width=width, + height=height, ) diff --git a/backend/app/crypto_animal_studio/application/render_tasks.py b/backend/app/crypto_animal_studio/application/render_tasks.py index 60937b42..4e2badcd 100644 --- a/backend/app/crypto_animal_studio/application/render_tasks.py +++ b/backend/app/crypto_animal_studio/application/render_tasks.py @@ -196,16 +196,28 @@ async def get_result(self) -> object: return None +#: Step 6 mock 流水线使用的供应商名(``providers/mock.py``)。 +#: mock 也会为每个镜头产出 ``ArtifactType.video`` 产物,因此**必须**把它排除在 +#: 真实渲染的幂等判定之外,否则「先用 mock 建任务、再发起真实渲染」时, +#: 真实渲染会误判为「已有产物」而秒回 succeeded,根本不会调用 ComfyUI。 +MOCK_VIDEO_PROVIDER = "mock-video" + + async def _existing_video_artifact( db: AsyncSession, *, job_id: str, production_shot_id: str ) -> CasProductionArtifact | None: - """查询该镜头是否已有视频产物(幂等与「保留既有成功产物」的依据)。""" + """查询该镜头是否已有**真实渲染**产生的视频产物。 + + 仅用于幂等与「保留既有成功产物」。mock 流水线的占位产物不算数: + 它由 Step 6 的模拟管线生成,并不代表任何供应商真的渲染过。 + """ stmt = ( select(CasProductionArtifact) .where( CasProductionArtifact.job_id == job_id, CasProductionArtifact.production_shot_id == production_shot_id, CasProductionArtifact.artifact_type == ArtifactType.video.value, + CasProductionArtifact.provider != MOCK_VIDEO_PROVIDER, ) .limit(1) ) @@ -277,8 +289,9 @@ async def run_cas_shot_render_task(task_id: str, run_args: dict | None = None) - store = SqlAlchemyTaskStore(db) task = await store.get(task_id) if task is None: - logger.warning("cas render task not found: %s", task_id) - return + # 不静默返回:任务行缺失属于真实故障,必须让 executor 记为 failed, + # 否则会出现「秒回 succeeded 但什么都没做」的假成功。 + raise RenderTaskError(f"render task record not found: {task_id}") if not run_args: run_args = task.payload.get("run_args") or {} await store.set_status(task_id, TaskStatus.running) @@ -291,7 +304,20 @@ async def run_cas_shot_render_task(task_id: str, run_args: dict | None = None) - attempt = int(run_args.get("attempt") or 1) snapshot = dict(run_args.get("request_snapshot") or {}) + # run_args 缺字段同样不得静默通过:缺 input 会让供应商拿到空提示词, + # 缺 base_url 会让 ComfyUI 适配器无从连接。这里提前失败并给出可读原因。 + missing = [ + key + for key in ("job_id", "production_shot_id", "provider", "input") + if not run_args.get(key) + ] try: + # 放在 try 内:这样缺字段失败也会走同一套「任务 + 生产镜头都标记 failed」的收尾。 + if missing: + raise RenderTaskError( + f"render run_args missing required fields: {sorted(missing)}" + ) + async with async_session_maker() as db: store = SqlAlchemyTaskStore(db) diff --git a/backend/tests/test_cas_render_api.py b/backend/tests/test_cas_render_api.py index 0d9307ad..1d623aa3 100644 --- a/backend/tests/test_cas_render_api.py +++ b/backend/tests/test_cas_render_api.py @@ -325,3 +325,46 @@ def test_unknown_chapter_returns_empty_not_all_jobs(client) -> None: ) assert resp.status_code == 200 assert resp.json()["data"] == [] + + +def test_render_route_accepts_preview_profile(client) -> None: + """profile=preview → 快照记录 432×768(精确 9:16)。""" + test_client, factory = client + import asyncio + + resp = test_client.post(f"{BASE}/jobs/{JOB_ID}/shots/{SHOT_ID}/render", params={"profile": "preview"}) + assert resp.status_code == 200 + + async def _snap(): + async with factory() as db: + task = (await db.execute(select(GenerationTask))).scalars().one() + return ((task.payload or {}).get("run_args") or {}) + + run_args = asyncio.run(_snap()) + assert run_args["input"]["width"] == 432 + assert run_args["input"]["height"] == 768 + assert run_args["request_snapshot"]["width"] == 432 + + +def test_render_route_defaults_to_final_for_api_compatibility(client) -> None: + """不传 profile → final:分辨率交给 ratio 推导,既有 API 兼容性不变。""" + test_client, factory = client + import asyncio + + assert _render(test_client).status_code == 200 + + async def _snap(): + async with factory() as db: + task = (await db.execute(select(GenerationTask))).scalars().one() + return ((task.payload or {}).get("run_args") or {}) + + run_args = asyncio.run(_snap()) + assert run_args["input"]["width"] is None + assert run_args["input"]["height"] is None + + +def test_render_route_rejects_unknown_profile(client) -> None: + """未知 profile 值 → 422,不静默退回某个档位。""" + test_client, _ = client + resp = test_client.post(f"{BASE}/jobs/{JOB_ID}/shots/{SHOT_ID}/render", params={"profile": "ultra"}) + assert resp.status_code == 422 diff --git a/backend/tests/test_cas_render_e2e.py b/backend/tests/test_cas_render_e2e.py index 0a740dcd..a67df72c 100644 --- a/backend/tests/test_cas_render_e2e.py +++ b/backend/tests/test_cas_render_e2e.py @@ -464,3 +464,64 @@ def test_worker_boundary_is_the_registered_executor() -> None: executor = task_executor_registry.resolve("cas_render_shot") assert executor.task_kind == "cas_render_shot" assert executor._runner is rt.run_cas_shot_render_task # pylint: disable=protected-access + + +def test_mock_artifact_does_not_short_circuit_a_real_render() -> None: + """回归:Step 6 的 mock 视频产物不得让真实渲染秒回 succeeded。 + + 这正是「API 返回 pending、Celery 收到任务、36ms 就 succeeded、ComfyUI 没收到 + workflow」的根因:mock 流水线为每个镜头产出 ArtifactType.video,旧的幂等判定 + 只看 (job, shot, type),于是把 mock 占位当成「已渲染」。 + """ + + async def _case(factory): + # 预置一条 mock 产物,模拟先用 mode=mock 建过任务 + async with factory() as db: + db.add( + CasProductionArtifact( + id="mock-art", + job_id=JOB_ID, + production_shot_id=SHOT_ID, + artifact_type="video", + stage="video_generation", + provider=rt.MOCK_VIDEO_PROVIDER, + provider_model="", + file_path="mock/shot.txt", + mime_type="video/mp4", + checksum="", + metadata_json={}, + ) + ) + await db.commit() + + task_id = await _start_attempt(factory) + await rt.run_cas_shot_render_task(task_id) + + # 供应商必须真的被调用(不是短路) + assert len(_FakeVideoTask.calls) == 1, "real render must call the provider" + + async with factory() as db: + arts = (await db.execute(select(CasProductionArtifact))).scalars().all() + row = await db.get(GenerationTask, task_id) + # mock 产物保留,另外新增一条真实渲染产物 + providers = sorted(a.provider for a in arts) + assert providers == ["comfyui", rt.MOCK_VIDEO_PROVIDER] + assert _status_of(row) == "succeeded" + assert (row.result or {}).get("reused") is False + + _run_env(_case) + + +def test_missing_run_args_fails_instead_of_silent_success() -> None: + """run_args 缺必要字段 → 明确 failed,绝不静默成功。""" + + async def _case(factory): + task_id = await _start_attempt(factory) + await rt.run_cas_shot_render_task(task_id, {"job_id": JOB_ID}) # 缺 input 等 + + async with factory() as db: + row = await db.get(GenerationTask, task_id) + assert _status_of(row) == "failed" + assert _FakeVideoTask.calls == [], "provider must not be called with invalid args" + + _run_env(_case) diff --git a/backend/tests/test_cas_render_request.py b/backend/tests/test_cas_render_request.py index 6fa6ac08..1a1ca261 100644 --- a/backend/tests/test_cas_render_request.py +++ b/backend/tests/test_cas_render_request.py @@ -120,3 +120,29 @@ def test_seed_change_changes_fingerprint_but_not_prompt() -> None: b = build_render_request(_Shot(), context=_context(), seed=2) assert a.prompt == b.prompt assert snapshot_fingerprint(a.snapshot) != snapshot_fingerprint(b.snapshot) + + +def test_explicit_dimensions_flow_into_video_input() -> None: + """预览档:显式分辨率进入供应商契约,并记入快照。""" + request = build_render_request(_Shot(), context=_context(), width=432, height=768) + assert (request.width, request.height) == (432, 768) + video_input = request.to_video_input() + assert (video_input.width, video_input.height) == (432, 768) + assert request.snapshot["width"] == 432 and request.snapshot["height"] == 768 + + +def test_omitting_dimensions_keeps_ratio_only_behaviour() -> None: + """成片档:不传分辨率时契约里仍为 None,由 ratio 推导(既有行为不变)。""" + request = build_render_request(_Shot(), context=_context()) + assert request.width is None and request.height is None + video_input = request.to_video_input() + assert video_input.width is None and video_input.height is None + assert video_input.ratio == "9:16" + + +def test_preview_and_final_snapshots_differ() -> None: + """同一提示词在两个档位下快照指纹不同,重试可区分。""" + preview = build_render_request(_Shot(), context=_context(), width=432, height=768) + final = build_render_request(_Shot(), context=_context()) + assert preview.prompt == final.prompt + assert snapshot_fingerprint(preview.snapshot) != snapshot_fingerprint(final.snapshot) diff --git a/backend/tests/test_comfyui_provider.py b/backend/tests/test_comfyui_provider.py index 7d278602..d2d47fcd 100644 --- a/backend/tests/test_comfyui_provider.py +++ b/backend/tests/test_comfyui_provider.py @@ -242,3 +242,132 @@ def test_read_execution_status_treats_absent_status_as_running() -> None: """尚无 status 字段视为仍在执行,而不是失败。""" assert read_execution_status({})[0] == "running" assert read_execution_status({"status": {"status_str": "success"}})[0] == "success" + + +def test_preview_profile_uses_exact_9_16_dimensions() -> None: + """预览档 432×768:精确 9:16,且原样注入工作流。""" + task = ComfyUIVideoGenerationTask( + adapter=_FakeAdapter([_success_history()]), + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(width=432, height=768), + poll_interval_s=0, + timeout_s=5, + ) + values = task._build_workflow_values() # pylint: disable=protected-access + assert (values["width"], values["height"]) == (432, 768) + assert 432 / 768 == 9 / 16 # 精确比例,不是 544/960 的 17:30 + + +def test_final_profile_falls_back_to_ratio_dimensions() -> None: + """成片档不传分辨率 → 由 ratio 推导 1080×1920(既有行为不变)。""" + task = ComfyUIVideoGenerationTask( + adapter=_FakeAdapter([_success_history()]), + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(), + poll_interval_s=0, + timeout_s=5, + ) + values = task._build_workflow_values() # pylint: disable=protected-access + assert (values["width"], values["height"]) == (1080, 1920) + + +# --------------------------------------------------------------------------- # +# fail-fast:缺少尺寸映射时禁止静默略过 +# --------------------------------------------------------------------------- # +def _mapping_without(keys: set[str], tmp_path: Path): + """构造一份去掉指定输入映射的 mapping。""" + original = json.loads(_MAPPING.read_text(encoding="utf-8")) + original["inputs"] = {k: v for k, v in original["inputs"].items() if k not in keys} + original["workflow_path"] = str(_FIXTURES / "example_workflow.api.json") + path = tmp_path / "m.json" + path.write_text(json.dumps(original), encoding="utf-8") + return load_mapping(path) + + +def test_require_render_inputs_passes_with_width_and_height() -> None: + """映射同时含 width/height → 校验通过。""" + from app.core.integrations.comfyui import require_render_inputs + + require_render_inputs(load_mapping(_MAPPING)) # 不抛异常即通过 + + +@pytest.mark.parametrize( + "removed,expected", + [({"width"}, "width"), ({"height"}, "height")], +) +def test_missing_single_dimension_mapping_fails(removed, expected, tmp_path: Path) -> None: + """缺少 width 或 height 之一 → 明确失败,错误列出该键。""" + from app.core.integrations.comfyui import require_render_inputs + + with pytest.raises(WorkflowConfigError, match="missing required inputs") as exc: + require_render_inputs(_mapping_without(removed, tmp_path)) + assert expected in str(exc.value) + + +def test_missing_both_dimensions_lists_both(tmp_path: Path) -> None: + """两者都缺 → 错误同时列出 width 与 height。""" + from app.core.integrations.comfyui import require_render_inputs + + with pytest.raises(WorkflowConfigError) as exc: + require_render_inputs(_mapping_without({"width", "height"}, tmp_path)) + message = str(exc.value) + assert "width" in message and "height" in message + assert message.startswith("ComfyUI workflow mapping is missing required inputs:") + + +@pytest.mark.asyncio +async def test_missing_mapping_never_submits_prompt(tmp_path: Path) -> None: + """校验失败时 submit_prompt 完全不被调用,任务也不会成功。""" + adapter = _FakeAdapter([_success_history()]) + task = ComfyUIVideoGenerationTask( + adapter=adapter, + mapping=_mapping_without({"width", "height"}, tmp_path), + provider_config=_cfg(), + input_=_input(width=432, height=768), + poll_interval_s=0, + timeout_s=5, + ) + await task.run() + + assert adapter.submit_calls == 0, "prompt must not be submitted when mapping is invalid" + assert await task.get_result() is None + status = await task.status() + assert "missing required inputs" in status["error"] + + +@pytest.mark.asyncio +async def test_preview_profile_actually_reaches_the_workflow() -> None: + """预览档:工作流节点真的收到 432×768。""" + adapter = _FakeAdapter([_success_history()]) + task = ComfyUIVideoGenerationTask( + adapter=adapter, + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(width=432, height=768), + poll_interval_s=0, + timeout_s=5, + ) + await task.run() + assert await task.get_result() is not None + assert adapter.submitted_prompt["5"]["inputs"]["width"] == 432 + assert adapter.submitted_prompt["5"]["inputs"]["height"] == 768 + + +@pytest.mark.asyncio +async def test_final_profile_actually_reaches_the_workflow() -> None: + """成片档:工作流节点真的收到 1080×1920。""" + adapter = _FakeAdapter([_success_history()]) + task = ComfyUIVideoGenerationTask( + adapter=adapter, + mapping=load_mapping(_MAPPING), + provider_config=_cfg(), + input_=_input(), + poll_interval_s=0, + timeout_s=5, + ) + await task.run() + assert await task.get_result() is not None + assert adapter.submitted_prompt["5"]["inputs"]["width"] == 1080 + assert adapter.submitted_prompt["5"]["inputs"]["height"] == 1920 diff --git a/backend/workflows/comfyui/cas_sd15_image_test.api.json b/backend/workflows/comfyui/cas_sd15_image_test.api.json new file mode 100644 index 00000000..e0b1f613 --- /dev/null +++ b/backend/workflows/comfyui/cas_sd15_image_test.api.json @@ -0,0 +1,107 @@ +{ + "3": { + "inputs": { + "seed": 804455692448001, + "steps": 10, + "cfg": 7, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": [ + "4", + 0 + ], + "positive": [ + "6", + 0 + ], + "negative": [ + "7", + 0 + ], + "latent_image": [ + "5", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "KSampler" + } + }, + "4": { + "inputs": { + "ckpt_name": "v1-5-pruned-emaonly-fp16.safetensors" + }, + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "載入檢查點" + } + }, + "5": { + "inputs": { + "width": 512, + "height": 512, + "batch_size": 1 + }, + "class_type": "EmptyLatentImage", + "_meta": { + "title": "空白潛在影像" + } + }, + "6": { + "inputs": { + "text": "a cute border collie dog, wearing a bartender uniform, standing behind a bar, colorful 3D animated movie style", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP 文字編碼(提示詞)" + } + }, + "7": { + "inputs": { + "text": "", + "clip": [ + "4", + 1 + ] + }, + "class_type": "CLIPTextEncode", + "_meta": { + "title": "CLIP 文字編碼(提示詞)" + } + }, + "8": { + "inputs": { + "samples": [ + "3", + 0 + ], + "vae": [ + "4", + 2 + ] + }, + "class_type": "VAEDecode", + "_meta": { + "title": "VAE 解碼" + } + }, + "9": { + "inputs": { + "filename_prefix": "SD1.5", + "images": [ + "8", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "儲存圖片" + } + } +} \ No newline at end of file diff --git a/backend/workflows/comfyui/cas_sd15_image_test_mapping.json b/backend/workflows/comfyui/cas_sd15_image_test_mapping.json new file mode 100644 index 00000000..947b02df --- /dev/null +++ b/backend/workflows/comfyui/cas_sd15_image_test_mapping.json @@ -0,0 +1,11 @@ +{ + "workflow_path": "cas_sd15_image_test.api.json", + "inputs": { + "positive_prompt": "6.text", + "negative_prompt": "7.text", + "width": "5.width", + "height": "5.height", + "seed": "3.seed" + }, + "output_node": "9" +} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index c07a615f..8f0b1422 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -26,3 +26,23 @@ RUSTFS_CONSOLE_PORT=9001 ## Front runtime config (served as /env.js) # 由于前后端分端口部署,浏览器应访问宿主机的后端地址 BACKEND_URL=http://localhost:8000 + +# --------------------------------------------------------------------------- +# Crypto Animal Studio — single-shot render (Step 7) +# --------------------------------------------------------------------------- +# Render provider: comfyui | volcengine | openai +CAS_RENDER_PROVIDER=comfyui +# Self-hosted ComfyUI instance. No default: rendering fails clearly if unset. +# From inside Docker use host.docker.internal to reach a host-side ComfyUI. +CAS_COMFYUI_BASE_URL=http://host.docker.internal:8188 +# Path to the workflow input/output mapping JSON. +# The workflow itself must be exported in ComfyUI API format. +# The mapping MUST map "width" and "height", otherwise a render fails fast. +CAS_COMFYUI_WORKFLOW_MAPPING=/app/workflows/cas_txt2video.mapping.json +# Poll interval and per-render timeout (seconds). +CAS_RENDER_POLL_INTERVAL_S=3.0 +CAS_RENDER_TIMEOUT_S=1800.0 +# Preview profile resolution. 432x768 is exact 9:16 and both are multiples of 8; +# roughly 16% of the 1080x1920 final profile, suitable for Intel Iris Xe. +CAS_RENDER_PREVIEW_WIDTH=432 +CAS_RENDER_PREVIEW_HEIGHT=768 diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index a0aa492d..15f0a6b9 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -96,6 +96,11 @@ services: CELERY_BROKER_URL: ${CELERY_BROKER_URL:-} DEBUG: "false" API_V1_PREFIX: /api/v1 + CAS_RENDER_PROVIDER: "comfyui" + CAS_COMFYUI_BASE_URL: "http://host.docker.internal:8188" + CAS_COMFYUI_WORKFLOW_MAPPING: "/app/workflows/comfyui/cas_sd15_image_test_mapping.json" + CAS_RENDER_PREVIEW_WIDTH: ${CAS_RENDER_PREVIEW_WIDTH:-432} + CAS_RENDER_PREVIEW_HEIGHT: ${CAS_RENDER_PREVIEW_HEIGHT:-768} # S3 / RustFS S3_ENDPOINT_URL: http://rustfs:9000 S3_REGION_NAME: us-east-1 @@ -131,6 +136,11 @@ services: CELERY_BROKER_URL: ${CELERY_BROKER_URL:-} DEBUG: "false" API_V1_PREFIX: /api/v1 + CAS_RENDER_PROVIDER: "comfyui" + CAS_COMFYUI_BASE_URL: "http://host.docker.internal:8188" + CAS_COMFYUI_WORKFLOW_MAPPING: "/app/workflows/comfyui/cas_sd15_image_test_mapping.json" + CAS_RENDER_PREVIEW_WIDTH: ${CAS_RENDER_PREVIEW_WIDTH:-432} + CAS_RENDER_PREVIEW_HEIGHT: ${CAS_RENDER_PREVIEW_HEIGHT:-768} S3_ENDPOINT_URL: http://rustfs:9000 S3_REGION_NAME: us-east-1 S3_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY} diff --git a/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx b/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx index 6d2186aa..61c94d08 100644 --- a/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx +++ b/front/src/pages/aiStudio/cas/Ep001Workspace.render.test.tsx @@ -117,13 +117,13 @@ describe('Ep001Workspace → ShotRenderPanel mounting', () => { is_terminal: false, } as never) fireEvent.click(screen.getByTestId('generate-video')) - await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith('job-1', 'pshot-1')) + await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith('job-1', 'pshot-1', 'preview')) // 切换到第二个镜头(accordion 会关掉第一个) fireEvent.click(screen.getAllByTestId('shot-row')[1]) await waitFor(() => expect(screen.getByTestId('render-panel')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('generate-video')) - await waitFor(() => expect(api.startShotRender).toHaveBeenLastCalledWith('job-1', 'pshot-2')) + await waitFor(() => expect(api.startShotRender).toHaveBeenLastCalledWith('job-1', 'pshot-2', 'preview')) }) it('shows a clear notice when no production shot maps to the selection', async () => { diff --git a/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx b/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx index db191c6e..979f1858 100644 --- a/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx +++ b/front/src/pages/aiStudio/cas/ShotRenderPanel.test.tsx @@ -107,6 +107,36 @@ describe('ShotRenderPanel', () => { expect(screen.getAllByTestId('artifact-item')).toHaveLength(1) }) + it('sends profile=preview by default', async () => { + vi.mocked(api.startShotRender).mockResolvedValue({ + task_id: 't9', status: 'pending', is_terminal: false, attempt: 1, + } as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('generate-video')).toBeInTheDocument()) + fireEvent.click(screen.getByTestId('generate-video')) + // 预览档是默认值:一般 Render 操作必须显式传 preview,不依赖后端默认 + await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith(JOB, SHOT, 'preview')) + }) + + it('sends profile=final after selecting the final option', async () => { + vi.mocked(api.startShotRender).mockResolvedValue({ + task_id: 't10', status: 'pending', is_terminal: false, attempt: 1, + } as never) + renderPanel() + await waitFor(() => expect(screen.getByTestId('render-profile')).toBeInTheDocument()) + fireEvent.click(screen.getByText(/正式渲染/)) + fireEvent.click(screen.getByTestId('generate-video')) + await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith(JOB, SHOT, 'final')) + }) + + it('shows both resolutions so the heavier option is explicit', async () => { + renderPanel() + await waitFor(() => expect(screen.getByTestId('render-profile')).toBeInTheDocument()) + expect(screen.getByText(/432×768/)).toBeInTheDocument() + expect(screen.getByText(/1080×1920/)).toBeInTheDocument() + expect(screen.getByText(/高负载/)).toBeInTheDocument() + }) + it('starts a render and disables the button while active', async () => { vi.mocked(api.startShotRender).mockResolvedValue({ task_id: 't9', @@ -119,7 +149,7 @@ describe('ShotRenderPanel', () => { renderPanel() await waitFor(() => expect(screen.getByTestId('generate-video')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('generate-video')) - await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith(JOB, SHOT)) + await waitFor(() => expect(api.startShotRender).toHaveBeenCalledWith(JOB, SHOT, 'preview')) await waitFor(() => expect(screen.getByTestId('generate-video').closest('button')).toBeDisabled(), ) diff --git a/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx b/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx index 6a1e49c1..be72c663 100644 --- a/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx +++ b/front/src/pages/aiStudio/cas/ShotRenderPanel.tsx @@ -8,7 +8,19 @@ * - 播放只用后端给的 download_url,绝不由 storage_key 拼地址。 */ import { useCallback, useEffect, useRef, useState } from 'react' -import { Alert, Button, Card, Descriptions, Empty, Progress, Space, Spin, Tag, Typography } from 'antd' +import { + Alert, + Button, + Card, + Descriptions, + Empty, + Progress, + Radio, + Space, + Spin, + Tag, + Typography, +} from 'antd' import { RENDER_POLL_INTERVAL_MS, @@ -17,6 +29,7 @@ import { fetchProductionJob, startShotRender, type RenderArtifactView, + type RenderProfile, type RenderTaskView, } from '../../../services/casWorkspaceApi' @@ -46,6 +59,8 @@ export default function ShotRenderPanel({ jobId, productionShotId }: ShotRenderP const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [submitting, setSubmitting] = useState(false) + // 默认预览档:低分辨率试跑,避免误触发高负载的成片渲染。 + const [profile, setProfile] = useState('preview') const mountedRef = useRef(true) const timerRef = useRef | null>(null) @@ -132,7 +147,7 @@ export default function ShotRenderPanel({ jobId, productionShotId }: ShotRenderP setSubmitting(true) setError(null) try { - const accepted = await startShotRender(jobId, productionShotId) + const accepted = await startShotRender(jobId, productionShotId, profile) if (!mountedRef.current) return setTask(accepted) const generation = generationRef.current @@ -142,7 +157,7 @@ export default function ShotRenderPanel({ jobId, productionShotId }: ShotRenderP } finally { if (mountedRef.current) setSubmitting(false) } - }, [jobId, productionShotId, scheduleNext]) + }, [jobId, productionShotId, profile, scheduleNext]) const active = !!task && !task.is_terminal const failed = task?.status === 'failed' @@ -170,6 +185,27 @@ export default function ShotRenderPanel({ jobId, productionShotId }: ShotRenderP /> )} + + setProfile(e.target.value as RenderProfile)} + disabled={active} + data-testid="render-profile" + > + + 预览渲染 · 432×768 + + + 正式渲染 · 1080×1920(高负载) + + + + {profile === 'preview' + ? '预览档:精确 9:16,像素量约为成片的 16%,适合核显试跑。' + : '正式档:完整成片规格,耗时与显存占用显著更高。'} + + +