[feat] 新增图像和视频生成选项API,支持获取当前默认模型的关键帧规格和动态比例选项,更新相关逻辑以适应新参数

This commit is contained in:
extreme committed 2026-04-15 15:30:43 +08:00
1 parent d7eeadae12
commit 22cb2de56f
56 files changed
+1004 -431

No files matched your search

@@ -65,7 +65,6 @@ async def create_video_generation_task(
reference_mode=body.reference_mode,
prompt=body.prompt,
images=body.images,
size=body.size,
ratio=body.ratio,
)
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from app.core.contracts.video_generation import VideoRatio
class VideoGenerationTaskRequest(BaseModel):
"""视频生成任务请求。"""
@@ -19,7 +20,5 @@ class VideoGenerationTaskRequest(BaseModel):
description="参考图 file_id 列表,数量需与 reference_mode 严格匹配",
)
size: str | None = Field(None, description="分辨率(可选),如 720x1280")
ratio: str | None = Field(None, description="画幅比例(可选),如 16:9 / 9:16")
ratio: VideoRatio = Field(..., description="视频画幅比例,如 16:9 / 9:16")
# seconds 由 ShotDetail.duration 自动确定;请求体不再接收覆盖值。
+28
View File
@@ -9,6 +9,7 @@ from app.dependencies import get_db
from app.models.llm import ModelCategoryKey
from app.schemas.common import ApiResponse, PaginatedData, created_response, empty_response, success_response
from app.schemas.llm import (
ImageGenerationOptionsRead,
ModelCreate,
ModelRead,
ModelSettingsRead,
@@ -17,6 +18,7 @@ from app.schemas.llm import (
ProviderCreate,
ProviderRead,
ProviderSupportedRead,
VideoGenerationOptionsRead,
ProviderUpdate,
)
from app.services.llm.manage import (
@@ -27,6 +29,8 @@ from app.services.llm.manage import (
get_model as get_model_service,
get_model_settings as get_model_settings_service,
get_provider as get_provider_service,
get_image_generation_options as get_image_generation_options_service,
get_video_generation_options as get_video_generation_options_service,
list_supported_providers as list_supported_providers_service,
list_models_paginated,
list_providers_paginated,
@@ -83,6 +87,30 @@ async def list_supported_providers(
return success_response(items)
@router.get(
"/image-generation-options",
response_model=ApiResponse[ImageGenerationOptionsRead],
summary="获取当前默认图片模型的关键帧规格选项",
)
async def get_image_generation_options(
db: AsyncSession = Depends(get_db),
) -> ApiResponse[ImageGenerationOptionsRead]:
data = await get_image_generation_options_service(db)
return success_response(data)
@router.get(
"/video-generation-options",
response_model=ApiResponse[VideoGenerationOptionsRead],
summary="获取当前默认视频模型的动态比例选项",
)
async def get_video_generation_options(
db: AsyncSession = Depends(get_db),
) -> ApiResponse[VideoGenerationOptionsRead]:
data = await get_video_generation_options_service(db)
return success_response(data)
@router.post(
"/providers",
response_model=ApiResponse[ProviderRead],
@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts.image_generation import ImageResolutionProfile, ImageTargetRatio
from app.dependencies import get_db
from app.models.studio import (
ShotDetail,
@@ -101,6 +102,14 @@ class ShotFrameImageTaskRequest(BaseModel):
"无效条目会被跳过。"
),
)
target_ratio: ImageTargetRatio = Field(
...,
description="目标视频画幅比例;关键帧将按该画幅生成,以提升后续视频参考稳定性",
)
resolution_profile: ImageResolutionProfile | None = Field(
"standard",
description="关键帧输出分辨率档位,默认 standard",
)
class ShotFramePromptRenderRequest(BaseModel):
@@ -386,6 +395,9 @@ async def create_shot_frame_image_generation_task(
relation_entity_id=str(shot_frame_image.id),
prompt=submission.prompt,
images=ref_images if ref_images else None,
target_ratio=body.target_ratio,
resolution_profile=body.resolution_profile,
purpose="video_reference",
render_context=submission_extra.get("render_context"),
)
return created_response(TaskCreated(task_id=task_id))
+3 -10
View File
@@ -32,10 +32,6 @@ from app.schemas.studio.projects import (
router = APIRouter()
PROJECT_ORDER_FIELDS = {"name", "created_at", "updated_at", "progress"}
VIDEO_RATIO_OPTIONS = ("16:9", "4:3", "1:1", "3:4", "9:16", "21:9")
VIDEO_SIZE_OPTIONS = ("1920x1080", "1280x720", "1080x1920", "720x1280", "1024x1024")
DEFAULT_VIDEO_RATIO = "16:9"
DEFAULT_VIDEO_SIZE = "1920x1080"
def _build_project_style_options() -> tuple[dict[ProjectVisualStyle, list[ProjectStyle]], dict[ProjectVisualStyle, ProjectStyle]]:
@@ -68,9 +64,10 @@ def _validate_project_style_combo(*, visual_style: ProjectVisualStyle, style: Pr
@router.get(
"/style-options",
response_model=ApiResponse[ProjectStyleOptionsRead],
summary="获取项目风格与视频参数候选项",
summary="获取项目风格候选项",
)
async def get_project_style_options() -> ApiResponse[ProjectStyleOptionsRead]:
async def get_project_style_options(
) -> ApiResponse[ProjectStyleOptionsRead]:
mapping, defaults = _build_project_style_options()
data = ProjectStyleOptionsRead(
visual_styles=[StyleOption(value=x.value, label=x.value) for x in ProjectVisualStyle],
@@ -79,10 +76,6 @@ async def get_project_style_options() -> ApiResponse[ProjectStyleOptionsRead]:
for visual, styles in mapping.items()
},
default_style_by_visual_style={visual.value: style.value for visual, style in defaults.items()},
default_video_ratio=DEFAULT_VIDEO_RATIO,
default_video_size=DEFAULT_VIDEO_SIZE,
video_ratios=[StyleOption(value=x, label=x) for x in VIDEO_RATIO_OPTIONS],
video_sizes=[StyleOption(value=x, label=x) for x in VIDEO_SIZE_OPTIONS],
)
return success_response(data)
@@ -9,6 +9,9 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.contracts.provider import ProviderKey
ResponseFormat = Literal["url", "b64_json"]
ImageTargetRatio = Literal["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "3:2", "2:3"]
ImageResolutionProfile = Literal["standard", "high"]
ImagePurpose = Literal["generic", "video_reference", "asset_image"]
class InputImageRef(BaseModel):
@@ -43,6 +46,18 @@ class ImageGenerationInput(BaseModel):
description="参考图片列表:存在时 OpenAI 走 /images/edits,火山映射为 image[]",
)
model: Optional[str] = Field(None, description="模型名称(如 gpt-image-1.5 / doubao-seedream-*)")
target_ratio: ImageTargetRatio | None = Field(
None,
description="目标画幅比例,用于视频参考帧等需要与视频构图对齐的场景",
)
resolution_profile: ImageResolutionProfile | None = Field(
None,
description="输出分辨率档位,如 standard / high;由供应商适配层映射为最终 size",
)
purpose: ImagePurpose = Field(
"generic",
description="生成用途,如 generic / video_reference / asset_image",
)
size: Optional[str] = Field(
None,
description="分辨率,如 1024x1024 / 1024x1536 等;不同供应商可选项不同",
@@ -16,6 +16,9 @@ def _strip_optional_b64(value: str | None) -> str | None:
return s if s else None
VideoRatio = Literal["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"]
class VideoGenerationInput(BaseModel):
"""视频生成输入:支持文本提示词 + 可选的三种帧参考图(纯 base64 或 data URL)。"""
@@ -28,7 +31,7 @@ class VideoGenerationInput(BaseModel):
key_frame_base64: Optional[str] = Field(None, description="关键帧图:纯 base64 或 data URL")
model: Optional[str] = Field(None, description="视频模型名称(可选,供应商透传)")
size: Optional[str] = Field(None, description="分辨率,如 720x1280(可选,供应商透传)")
ratio: VideoRatio = Field(..., description="视频宽高比,业务层唯一主参数")
seconds: Optional[int] = Field(None, description="时长(秒)(可选,供应商透传)")
seed: Optional[int] = Field(
None,
@@ -37,10 +40,6 @@ class VideoGenerationInput(BaseModel):
description="随机种子,-1 或 [0, 2^32-1],供应商/模型可能有差异",
)
watermark: Optional[bool] = Field(None, description="是否包含水印,供应商/模型可能有差异")
ratio: Optional[Literal["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"]] = Field(
None,
description="视频宽高比,供应商/模型可能有差异",
)
@model_validator(mode="after")
def require_prompt_or_any_reference(self) -> "VideoGenerationInput":
@@ -7,6 +7,17 @@ from dataclasses import dataclass
from app.core.contracts.image_generation import ImageGenerationInput
from app.core.contracts.provider import ProviderKey
DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP: dict[str, dict[str, str]] = {
"16:9": {"standard": "1792x1024", "high": "2048x1152"},
"4:3": {"standard": "1536x1152", "high": "2048x1536"},
"1:1": {"standard": "1024x1024", "high": "1536x1536"},
"3:2": {"standard": "1536x1024", "high": "2048x1365"},
"2:3": {"standard": "1024x1536", "high": "1365x2048"},
"3:4": {"standard": "1152x1536", "high": "1536x2048"},
"9:16": {"standard": "1024x1792", "high": "1152x2048"},
"21:9": {"standard": "2048x896", "high": "2304x1024"},
}
@dataclass(frozen=True, slots=True)
class ImageModelCapability:
@@ -15,6 +26,9 @@ class ImageModelCapability:
supports_seed: bool = True
supports_watermark: bool = True
allowed_sizes: set[str] | None = None
supported_ratios: set[str] | None = None
default_resolution_profile: str | None = "standard"
ratio_size_profiles: dict[str, dict[str, str]] | None = None
min_n: int | None = 1
max_n: int | None = 10
@@ -61,6 +75,45 @@ def resolve_image_capability(*, provider: ProviderKey, model: str | None) -> Ima
return resolve_volcengine_image_capability(model)
def resolve_image_size(
*,
provider: ProviderKey,
model: str | None,
purpose: str,
target_ratio: str | None,
resolution_profile: str | None,
requested_size: str | None,
) -> str | None:
"""解析图片最终 size。
普通图片任务优先保留显式传入的 requested_size;
视频参考帧场景则优先根据 target_ratio + resolution_profile 从 capability 推导,
以保证关键帧与目标视频画幅保持一致。
"""
if purpose != "video_reference":
return requested_size
ratio = (target_ratio or "").strip()
if not ratio:
return requested_size
cap = resolve_image_capability(provider=provider, model=model)
if cap.supported_ratios is not None and ratio not in cap.supported_ratios:
raise ValueError(
f"Unsupported target_ratio for provider={provider} model={model or '<default>'}: {ratio}. "
f"Allowed: {sorted(cap.supported_ratios)}"
)
profile = (resolution_profile or cap.default_resolution_profile or "standard").strip() or "standard"
profiles = cap.ratio_size_profiles or DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP
size = profiles.get(ratio, {}).get(profile)
if size:
return size
fallback_profiles = DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP.get(ratio, {})
return fallback_profiles.get(profile) or fallback_profiles.get("standard") or requested_size
def validate_image_options(
*,
provider: ProviderKey,
+30 -20
View File
@@ -17,6 +17,7 @@ from app.core.contracts.image_generation import (
ImageItem,
)
from app.core.contracts.provider import ProviderConfig
from app.core.integrations.image_capabilities import resolve_image_size
from app.core.integrations.openai.image_capabilities import validate_openai_image_options
@@ -36,31 +37,40 @@ class OpenAIImageApiAdapter:
raise RuntimeError("httpx is required for image generation tasks") from e
base_url = (cfg.base_url or "https://api.openai.com/v1").rstrip("/")
validate_openai_image_options(inp)
resolved_size = resolve_image_size(
provider="openai",
model=inp.model,
purpose=inp.purpose,
target_ratio=inp.target_ratio,
resolution_profile=inp.resolution_profile,
requested_size=inp.size,
)
resolved_input = inp.model_copy(update={"size": resolved_size})
validate_openai_image_options(resolved_input)
headers = {
"Authorization": f"Bearer {cfg.api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=timeout_s) as client:
if inp.images:
if resolved_input.images:
body: dict[str, Any] = {
"prompt": inp.prompt,
"n": inp.n,
"prompt": resolved_input.prompt,
"n": resolved_input.n,
}
if inp.model:
body["model"] = inp.model
if inp.size:
body["size"] = inp.size
if inp.watermark is not None:
body["watermark"] = bool(inp.watermark)
if resolved_input.model:
body["model"] = resolved_input.model
if resolved_input.size:
body["size"] = resolved_input.size
if resolved_input.watermark is not None:
body["watermark"] = bool(resolved_input.watermark)
body["images"] = [
{
**({"file_id": ref.file_id} if ref.file_id else {}),
**({"image_url": ref.image_url} if ref.image_url else {}),
}
for ref in inp.images
for ref in resolved_input.images
]
url = f"{base_url}/images/edits"
@@ -75,16 +85,16 @@ class OpenAIImageApiAdapter:
r = await client.post(url, headers=headers, json=body)
else:
body = {
"prompt": inp.prompt,
"n": inp.n,
"response_format": inp.response_format,
"prompt": resolved_input.prompt,
"n": resolved_input.n,
"response_format": resolved_input.response_format,
}
if inp.model:
body["model"] = inp.model
if inp.size:
body["size"] = inp.size
if inp.watermark is not None:
body["watermark"] = bool(inp.watermark)
if resolved_input.model:
body["model"] = resolved_input.model
if resolved_input.size:
body["size"] = resolved_input.size
if resolved_input.watermark is not None:
body["watermark"] = bool(resolved_input.watermark)
url = f"{base_url}/images/generations"
t0 = time.perf_counter()
@@ -13,6 +13,7 @@ _OPENAI_DEFAULT = VideoModelCapability(
supports_seed=True,
supports_watermark=True,
allowed_ratios=set(ALLOWED_RATIOS),
default_ratio="16:9",
)
# key: 模型前缀(小写)
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any
from app.core.integrations.openai.video_capabilities import validate_openai_video_options
from app.core.integrations.video_capabilities import resolve_effective_ratio
from app.core.integrations.video_capabilities import derive_provider_size, resolve_effective_ratio
from app.core.contracts.video_generation import VideoGenerationInput, _strip_optional_b64
@@ -33,8 +33,9 @@ def build_create_video_body(input_: VideoGenerationInput) -> dict[str, Any]:
body: dict[str, Any] = {"prompt": input_.prompt or ""}
if input_.model:
body["model"] = input_.model
if input_.size:
body["size"] = input_.size
size = derive_provider_size(provider="openai", model=input_.model, ratio=input_.ratio)
if size:
body["size"] = size
if input_.seconds is not None:
body["seconds"] = str(int(input_.seconds))
effective_ratio = resolve_effective_ratio(input_)
@@ -3,12 +3,19 @@
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
from app.core.contracts.video_generation import VideoGenerationInput, VideoRatio
ALLOWED_RATIOS = {"16:9", "4:3", "1:1", "3:4", "9:16", "21:9"}
DEFAULT_RATIO_TO_SIZE_MAPPING: dict[str, str] = {
"16:9": "1280x720",
"4:3": "1024x768",
"1:1": "1024x1024",
"3:4": "768x1024",
"9:16": "720x1280",
"21:9": "1680x720",
}
@dataclass(frozen=True, slots=True)
@@ -18,7 +25,8 @@ class VideoModelCapability:
supports_seed: bool = True
supports_watermark: bool = True
allowed_ratios: set[str] | None = None
allowed_sizes: set[str] | None = None
default_ratio: str | None = None
ratio_to_size_mapping: dict[str, str] | None = None
min_seconds: int | None = 1
max_seconds: int | None = None
@@ -65,32 +73,28 @@ def resolve_video_capability(*, provider: ProviderKey, model: str | None) -> Vid
return resolve_volcengine_video_capability(model)
def infer_ratio_from_size(size: str | None) -> str | None:
if not size:
return None
value = size.strip().lower()
if not value:
return None
if ":" in value:
return value if value in ALLOWED_RATIOS else None
if "x" not in value:
return None
left, right = value.split("x", 1)
if not left.isdigit() or not right.isdigit():
return None
width = int(left)
height = int(right)
if width <= 0 or height <= 0:
return None
factor = gcd(width, height)
ratio = f"{width // factor}:{height // factor}"
return ratio if ratio in ALLOWED_RATIOS else None
def resolve_effective_ratio(input_: VideoGenerationInput) -> str | None:
if input_.ratio:
return input_.ratio
return infer_ratio_from_size(input_.size)
return input_.ratio
def resolve_default_ratio(*, provider: ProviderKey, model: str | None) -> str | None:
cap = resolve_video_capability(provider=provider, model=model)
if cap.default_ratio:
return cap.default_ratio
if cap.allowed_ratios:
return sorted(cap.allowed_ratios)[0]
return "16:9"
def derive_provider_size(
*,
provider: ProviderKey,
model: str | None,
ratio: VideoRatio,
) -> str | None:
cap = resolve_video_capability(provider=provider, model=model)
mapping = cap.ratio_to_size_mapping or DEFAULT_RATIO_TO_SIZE_MAPPING
return mapping.get(ratio)
def validate_video_options(
@@ -100,21 +104,11 @@ def validate_video_options(
input_: VideoGenerationInput,
) -> None:
cap = resolve_video_capability(provider=provider, model=model)
inferred_ratio = infer_ratio_from_size(input_.size)
if input_.ratio and cap.allowed_ratios is not None and input_.ratio not in cap.allowed_ratios:
raise ValueError(
f"Unsupported ratio for provider={provider} model={model or '<default>'}: {input_.ratio}. "
f"Allowed: {sorted(cap.allowed_ratios)}"
)
if input_.ratio and inferred_ratio and inferred_ratio != input_.ratio:
raise ValueError(
f"ratio conflicts with size: ratio={input_.ratio}, size={input_.size} (implies {inferred_ratio})"
)
if input_.size and cap.allowed_sizes is not None and input_.size not in cap.allowed_sizes:
raise ValueError(
f"Unsupported size for provider={provider} model={model or '<default>'}: {input_.size}. "
f"Allowed: {sorted(cap.allowed_sizes)}"
)
if input_.seconds is not None:
if cap.min_seconds is not None and input_.seconds < cap.min_seconds:
raise ValueError(f"seconds must be >= {cap.min_seconds}")
@@ -9,9 +9,28 @@ from app.core.integrations.image_capabilities import ImageModelCapability
if TYPE_CHECKING:
from app.core.contracts.image_generation import ImageGenerationInput
_VOLCENGINE_RATIO_SIZE_PROFILES = {
"1:1": {"standard": "2048x2048", "high": "3072x3072"},
"4:3": {"standard": "2304x1728", "high": "3456x2592"},
"3:4": {"standard": "1728x2304", "high": "2592x3456"},
"16:9": {"standard": "2848x1600", "high": "4096x2304"},
"9:16": {"standard": "1600x2848", "high": "2304x4096"},
"3:2": {"standard": "2496x1664", "high": "3744x2496"},
"2:3": {"standard": "1664x2496", "high": "2496x3744"},
"21:9": {"standard": "3136x1344", "high": "4704x2016"},
}
_VOLCENGINE_DEFAULT = ImageModelCapability(
supports_seed=True,
supports_watermark=True,
allowed_sizes={
size
for profiles in _VOLCENGINE_RATIO_SIZE_PROFILES.values()
for size in profiles.values()
},
supported_ratios=set(_VOLCENGINE_RATIO_SIZE_PROFILES.keys()),
default_resolution_profile="standard",
ratio_size_profiles=_VOLCENGINE_RATIO_SIZE_PROFILES,
)
# key: 模型前缀(小写)
@@ -17,6 +17,7 @@ from app.core.contracts.image_generation import (
ImageItem,
)
from app.core.contracts.provider import ProviderConfig
from app.core.integrations.image_capabilities import resolve_image_size
from app.core.integrations.volcengine.image_capabilities import validate_volcengine_image_options
@@ -36,13 +37,22 @@ class VolcengineImageApiAdapter:
raise RuntimeError("httpx is required for image generation tasks") from e
base_url = (cfg.base_url or "https://ark.cn-beijing.volces.com/api/v3").rstrip("/")
validate_volcengine_image_options(inp)
resolved_size = resolve_image_size(
provider="volcengine",
model=inp.model,
purpose=inp.purpose,
target_ratio=inp.target_ratio,
resolution_profile=inp.resolution_profile,
requested_size=inp.size,
)
resolved_input = inp.model_copy(update={"size": resolved_size})
validate_volcengine_image_options(resolved_input)
headers = {
"Authorization": f"Bearer {cfg.api_key}",
"Content-Type": "application/json",
}
body = _build_image_body(inp)
body = _build_image_body(resolved_input)
async with httpx.AsyncClient(timeout=timeout_s) as client:
url = f"{base_url}/images/generations"
@@ -13,6 +13,7 @@ _VOLCENGINE_DEFAULT = VideoModelCapability(
supports_seed=True,
supports_watermark=True,
allowed_ratios=set(ALLOWED_RATIOS),
default_ratio="16:9",
)
# key: 模型前缀(小写)
@@ -10,15 +10,6 @@ from app.core.integrations.volcengine.video_capabilities import validate_volceng
from app.core.contracts.video_generation import VideoGenerationInput, _strip_optional_b64
def volcengine_ratio(size: str | None) -> str:
if not size or not str(size).strip():
return "adaptive"
s = str(size).strip()
if s.lower() == "adaptive" or ":" in s:
return s
return "adaptive"
def build_content(input_: VideoGenerationInput) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
prompt = (input_.prompt or "").strip()
@@ -61,7 +52,7 @@ def build_create_task_body(input_: VideoGenerationInput) -> dict[str, Any]:
if not content:
raise RuntimeError("Volcengine video requires non-empty content (prompt and/or reference frames)")
effective_ratio = resolve_effective_ratio(input_) or volcengine_ratio(input_.size)
effective_ratio = resolve_effective_ratio(input_)
body: dict[str, Any] = {
"content": content,
"ratio": effective_ratio,
-7
View File
@@ -36,12 +36,6 @@ class Project(Base, TimestampMixin):
seed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="随机种子")
unify_style: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否统一风格(跨章节)")
progress: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="进度百分比(0-100)")
default_video_size: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
default=None,
comment="项目级默认视频尺寸(可为空;分镜未覆盖时使用)",
)
default_video_ratio: Mapped[str | None] = mapped_column(
String(16),
nullable=True,
@@ -250,4 +244,3 @@ __all__ = [
"ProjectPropLink",
"ProjectCostumeLink",
]
-6
View File
@@ -171,12 +171,6 @@ class ShotDetail(Base,TimestampMixin):
comment="关联场景 ID(可空);应用层需保证与镜头所属项目一致或全局",
)
duration: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="时长(秒);镜头唯一时长来源")
override_video_size: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
default=None,
comment="分镜级视频尺寸覆盖(为空表示继承项目默认)",
)
override_video_ratio: Mapped[str | None] = mapped_column(
String(16),
nullable=True,
+24
View File
@@ -72,6 +72,30 @@ class ProviderSupportedRead(BaseModel):
is_experimental: bool = Field(False, description="是否实验性供应商")
class VideoGenerationOptionsRead(BaseModel):
"""当前默认视频模型对应的生成参数选项。"""
provider: str = Field(..., description="供应商稳定键")
model_id: str = Field(..., description="默认视频模型 ID")
model_name: str = Field(..., description="默认视频模型名称")
allowed_ratios: list[str] = Field(default_factory=list, description="当前模型允许的比例选项")
default_ratio: str = Field(..., description="当前模型默认比例")
class ImageGenerationOptionsRead(BaseModel):
"""当前默认图片模型对应的关键帧规格选项。"""
provider: str = Field(..., description="供应商稳定键")
model_id: str = Field(..., description="默认图片模型 ID")
model_name: str = Field(..., description="默认图片模型名称")
supported_ratios: list[str] = Field(default_factory=list, description="当前模型支持的目标比例")
default_resolution_profile: str = Field(..., description="当前模型默认分辨率档位")
ratio_size_profiles: dict[str, dict[str, str]] = Field(
default_factory=dict,
description="按比例和分辨率档位映射得到的像素尺寸",
)
class ModelBase(BaseModel):
"""模型通用字段。"""
+1 -13
View File
@@ -20,7 +20,6 @@ class ProjectBase(BaseModel):
seed: int = Field(0, description="随机种子")
unify_style: bool = Field(True, description="是否统一风格")
progress: int = Field(0, description="进度百分比(0-100)")
default_video_size: str | None = Field(None, description="项目级默认视频尺寸;分镜未覆盖时生效")
default_video_ratio: str | None = Field(None, description="项目级默认视频比例;分镜未覆盖时生效")
stats: dict[str, Any] = Field(default_factory=dict, description="聚合统计(JSON)")
@@ -37,7 +36,6 @@ class ProjectUpdate(BaseModel):
seed: int | None = None
unify_style: bool | None = None
progress: int | None = None
default_video_size: str | None = None
default_video_ratio: str | None = None
stats: dict[str, Any] | None = None
@@ -89,7 +87,7 @@ class StyleOption(BaseModel):
class ProjectStyleOptionsRead(BaseModel):
"""项目风格与视频参数候选项。"""
"""项目风格候选项。"""
visual_styles: list[StyleOption] = Field(default_factory=list, description="视觉风格可选项")
styles_by_visual_style: dict[str, list[StyleOption]] = Field(
@@ -100,13 +98,3 @@ class ProjectStyleOptionsRead(BaseModel):
default_factory=dict,
description="各视觉风格默认视频风格",
)
default_video_ratio: str | None = Field(
None,
description="默认视频比例(用于创建项目时的初始值)",
)
default_video_size: str | None = Field(
None,
description="默认视频尺寸(用于创建项目时的初始值)",
)
video_ratios: list[StyleOption] = Field(default_factory=list, description="视频比例候选项")
video_sizes: list[StyleOption] = Field(default_factory=list, description="视频尺寸候选项")
-2
View File
@@ -132,7 +132,6 @@ class ShotDetailBase(BaseModel):
movement: CameraMovement = Field(..., description="运镜方式")
scene_id: str | None = Field(None, description="关联场景 ID(可空)")
duration: int = Field(0, description="时长(秒)")
override_video_size: str | None = Field(None, description="分镜级视频尺寸覆盖;为空表示继承项目默认")
override_video_ratio: str | None = Field(None, description="分镜级视频比例覆盖;为空表示继承项目默认")
mood_tags: list[str] = Field(default_factory=list, description="情绪标签")
atmosphere: str = Field("", description="氛围描述")
@@ -164,7 +163,6 @@ class ShotDetailUpdate(BaseModel):
movement: CameraMovement | None = None
scene_id: str | None = None
duration: int | None = None
override_video_size: str | None = None
override_video_ratio: str | None = None
mood_tags: list[str] | None = None
atmosphere: str | None = None
+8 -52
View File
@@ -16,9 +16,8 @@ from app.core.contracts.video_generation import VideoGenerationInput, VideoGener
from app.core.tasks import VideoGenerationTask
from app.models.llm import Model, ModelCategoryKey, ModelSettings
from app.models.task_links import GenerationTaskLink
from app.models.studio import Chapter, FileItem, Project, Shot, ShotDetail, ShotFrameType
from app.models.studio import FileItem, Shot, ShotDetail, ShotFrameType
from app.models.types import FileUsageKind
from app.core.integrations.video_capabilities import infer_ratio_from_size
from app.services.common import entity_not_found
from app.services.llm.provider_resolver import resolve_provider_config_by_model
from app.services.studio.file_usages import sync_usage_from_shot_context
@@ -131,48 +130,13 @@ def _normalize_optional_text(value: str | None) -> str | None:
async def resolve_effective_video_options(
db: AsyncSession,
*,
shot_id: str,
shot_detail: ShotDetail,
requested_size: str | None,
requested_ratio: str | None,
) -> tuple[str | None, str | None]:
"""解析视频参数优先级:请求参数 > 分镜覆盖 > 项目默认。"""
req_size = _normalize_optional_text(requested_size)
) -> str:
"""解析视频比例:请求参数为唯一主参数。"""
req_ratio = _normalize_optional_text(requested_ratio)
shot_size = _normalize_optional_text(getattr(shot_detail, "override_video_size", None))
shot_ratio = _normalize_optional_text(getattr(shot_detail, "override_video_ratio", None))
project_size: str | None = None
project_ratio: str | None = None
if req_size is None or req_ratio is None:
stmt = (
select(Project.default_video_size, Project.default_video_ratio)
.select_from(Shot)
.join(Chapter, Chapter.id == Shot.chapter_id)
.join(Project, Project.id == Chapter.project_id)
.where(Shot.id == shot_id)
.limit(1)
)
row = (await db.execute(stmt)).first()
if row is not None:
project_size = _normalize_optional_text(row[0])
project_ratio = _normalize_optional_text(row[1])
resolved_size = req_size or shot_size or project_size
resolved_ratio = req_ratio or shot_ratio or project_ratio
implied_ratio = infer_ratio_from_size(resolved_size)
if resolved_ratio and implied_ratio and implied_ratio != resolved_ratio:
raise HTTPException(
status_code=400,
detail=(
f"ratio conflicts with size: ratio={resolved_ratio}, "
f"size={resolved_size} (implies {implied_ratio})"
),
)
return resolved_size, resolved_ratio
if not req_ratio:
raise HTTPException(status_code=400, detail="ratio is required")
return req_ratio
async def build_run_args(
@@ -182,20 +146,13 @@ async def build_run_args(
reference_mode: str,
prompt: str | None,
images: list[str],
size: str | None,
ratio: str | None,
) -> dict:
model = await resolve_default_video_model(db)
provider_cfg = await load_provider_config_by_model(db, model)
shot_detail = await validate_shot_and_duration(db, shot_id)
resolved_size, resolved_ratio = await resolve_effective_video_options(
db,
shot_id=shot_id,
shot_detail=shot_detail,
requested_size=size,
requested_ratio=ratio,
)
base = build_video_base_draft(shot_id=shot_id, prompt=prompt, size=resolved_size)
resolved_ratio = await resolve_effective_video_options(requested_ratio=ratio)
base = build_video_base_draft(shot_id=shot_id, prompt=prompt)
context = await build_video_context(
db,
shot_id=shot_id,
@@ -224,7 +181,6 @@ async def build_run_args(
"last_frame_base64": frame_map.get(ShotFrameType.last),
"key_frame_base64": frame_map.get(ShotFrameType.key),
"model": model.name,
"size": resolved_size,
"ratio": resolved_ratio,
"seconds": shot_detail.duration,
},
+73
View File
@@ -8,8 +8,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.utils import apply_keyword_filter, apply_order, paginate
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider
from app.core.integrations.image_capabilities import (
DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP,
resolve_image_capability,
)
from app.core.integrations.video_capabilities import resolve_default_ratio, resolve_video_capability
from app.schemas.common import ApiResponse, PaginatedData, paginated_response
from app.schemas.llm import (
ImageGenerationOptionsRead,
ModelCreate,
ModelRead,
ModelSettingsUpdate,
@@ -17,6 +23,7 @@ from app.schemas.llm import (
ProviderCreate,
ProviderRead,
ProviderSupportedRead,
VideoGenerationOptionsRead,
ProviderUpdate,
)
from app.services.llm.provider_registry import (
@@ -276,6 +283,72 @@ async def update_model_settings(
return await flush_and_refresh(db, settings)
async def get_video_generation_options(
db: AsyncSession,
) -> VideoGenerationOptionsRead:
"""返回当前默认视频模型的动态 ratio 枚举。"""
settings = await get_or_create_settings(db)
model_id = settings.default_video_model_id
if not model_id:
return VideoGenerationOptionsRead(
provider="",
model_id="",
model_name="",
allowed_ratios=["16:9"],
default_ratio="16:9",
)
model = await get_or_404(db, Model, model_id, detail=entity_not_found("Model"))
provider = await get_or_404(db, Provider, model.provider_id, detail=entity_not_found("Provider"))
provider_key = resolve_provider_key_from_name(provider.name)
capability = resolve_video_capability(provider=provider_key, model=model.name)
allowed_ratios = sorted(capability.allowed_ratios or {"16:9"})
default_ratio = resolve_default_ratio(provider=provider_key, model=model.name) or allowed_ratios[0]
if default_ratio not in allowed_ratios:
allowed_ratios = sorted({*allowed_ratios, default_ratio})
return VideoGenerationOptionsRead(
provider=provider_key,
model_id=model.id,
model_name=model.name,
allowed_ratios=allowed_ratios,
default_ratio=default_ratio,
)
async def get_image_generation_options(
db: AsyncSession,
) -> ImageGenerationOptionsRead:
"""返回当前默认图片模型对应的关键帧比例/像素规格选项。"""
settings = await get_or_create_settings(db)
model_id = settings.default_image_model_id
if not model_id:
return ImageGenerationOptionsRead(
provider="",
model_id="",
model_name="",
supported_ratios=sorted(DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP.keys()),
default_resolution_profile="standard",
ratio_size_profiles=DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP,
)
model = await get_or_404(db, Model, model_id, detail=entity_not_found("Model"))
provider = await get_or_404(db, Provider, model.provider_id, detail=entity_not_found("Provider"))
provider_key = resolve_provider_key_from_name(provider.name)
capability = resolve_image_capability(provider=provider_key, model=model.name)
ratio_size_profiles = capability.ratio_size_profiles or DEFAULT_VIDEO_REFERENCE_RATIO_SIZE_MAP
supported_ratios = sorted(capability.supported_ratios or ratio_size_profiles.keys())
return ImageGenerationOptionsRead(
provider=provider_key,
model_id=model.id,
model_name=model.name,
supported_ratios=supported_ratios,
default_resolution_profile=capability.default_resolution_profile or "standard",
ratio_size_profiles=ratio_size_profiles,
)
def list_supported_providers(*, category: ModelCategoryKey | None) -> list[ProviderSupportedRead]:
# 防御性初始化:保证在非应用生命周期上下文(如单测)下也可返回内置清单。
bootstrap_all_registries()
@@ -9,18 +9,14 @@ class VideoBaseDraft(GenerationBaseDraft):
kind: str = "video"
shot_id: str
prompt: str
size: str | None = None
def build_video_base_draft(
*,
shot_id: str,
prompt: str | None,
size: str | None = None,
) -> VideoBaseDraft:
return VideoBaseDraft(
shot_id=shot_id,
prompt=(prompt or "").strip(),
size=(size or "").strip() or None,
)
@@ -246,6 +246,9 @@ async def create_image_task_and_link(
relation_entity_id: str,
prompt: str,
images: list[dict[str, str]] | None = None,
target_ratio: str | None = None,
resolution_profile: str | None = None,
purpose: str = "generic",
render_context: dict | None = None,
) -> str:
"""创建图片生成任务,并建立任务关联。"""
@@ -264,6 +267,9 @@ async def create_image_task_and_link(
"input": {
"prompt": prompt,
"model": model.name,
"target_ratio": target_ratio,
"resolution_profile": resolution_profile,
"purpose": purpose,
},
}
if images:
@@ -1,20 +1,3 @@
SET @has_projects_default_video_size = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'projects'
AND COLUMN_NAME = 'default_video_size'
);
SET @add_projects_default_video_size = IF(
@has_projects_default_video_size = 0,
"ALTER TABLE projects ADD COLUMN default_video_size VARCHAR(32) NULL COMMENT '项目级默认视频尺寸(可为空;分镜未覆盖时使用)'",
'SELECT 1'
);
PREPARE stmt_add_projects_default_video_size FROM @add_projects_default_video_size;
EXECUTE stmt_add_projects_default_video_size;
DEALLOCATE PREPARE stmt_add_projects_default_video_size;
SET @has_projects_default_video_ratio = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
@@ -32,23 +15,6 @@ PREPARE stmt_add_projects_default_video_ratio FROM @add_projects_default_video_r
EXECUTE stmt_add_projects_default_video_ratio;
DEALLOCATE PREPARE stmt_add_projects_default_video_ratio;
SET @has_shot_details_override_video_size = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'shot_details'
AND COLUMN_NAME = 'override_video_size'
);
SET @add_shot_details_override_video_size = IF(
@has_shot_details_override_video_size = 0,
"ALTER TABLE shot_details ADD COLUMN override_video_size VARCHAR(32) NULL COMMENT '分镜级视频尺寸覆盖(为空表示继承项目默认)'",
'SELECT 1'
);
PREPARE stmt_add_shot_details_override_video_size FROM @add_shot_details_override_video_size;
EXECUTE stmt_add_shot_details_override_video_size;
DEALLOCATE PREPARE stmt_add_shot_details_override_video_size;
SET @has_shot_details_override_video_ratio = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
@@ -78,6 +78,40 @@ async def test_openai_image_adapter_edits_when_references(monkeypatch: pytest.Mo
assert result.images[0].b64_json == "abc"
@pytest.mark.asyncio
async def test_openai_image_adapter_resolves_video_reference_size(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = request.content.decode()
return httpx.Response(200, json={"data": [{"url": "https://cdn.example.com/ref.png"}]})
_patch_httpx_client(monkeypatch, httpx.MockTransport(handler))
clear_image_model_capability_overrides(provider="openai")
register_image_model_capability(
provider="openai",
model_prefix="gpt-image-video-ref",
capability=ImageModelCapability(
supported_ratios={"16:9"},
ratio_size_profiles={"16:9": {"standard": "1792x1024"}},
),
)
cfg = ProviderConfig(provider="openai", api_key="sk-test", base_url="https://api.openai.com/v1")
inp = ImageGenerationInput(
prompt="video ref",
model="gpt-image-video-ref-1",
target_ratio="16:9",
resolution_profile="standard",
purpose="video_reference",
)
try:
await OpenAIImageApiAdapter().generate(cfg=cfg, inp=inp, timeout_s=30.0)
body = json.loads(captured["body"])
assert body["size"] == "1792x1024"
finally:
clear_image_model_capability_overrides(provider="openai")
@pytest.mark.asyncio
async def test_volcengine_image_adapter_generations(monkeypatch: pytest.MonkeyPatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
@@ -86,6 +120,7 @@ async def test_volcengine_image_adapter_generations(monkeypatch: pytest.MonkeyPa
assert payload["prompt"] == "火山"
assert payload["n"] == 1
assert payload["watermark"] is True
assert payload["size"] == "1600x2848"
return httpx.Response(
200,
json={
@@ -97,7 +132,15 @@ async def test_volcengine_image_adapter_generations(monkeypatch: pytest.MonkeyPa
_patch_httpx_client(monkeypatch, httpx.MockTransport(handler))
cfg = ProviderConfig(provider="volcengine", api_key="ak-test")
inp = ImageGenerationInput(prompt="火山", n=1, seed=42, watermark=True)
inp = ImageGenerationInput(
prompt="火山",
n=1,
seed=42,
watermark=True,
target_ratio="9:16",
resolution_profile="standard",
purpose="video_reference",
)
result = await VolcengineImageApiAdapter().generate(cfg=cfg, inp=inp, timeout_s=30.0)
assert result.provider == "volcengine"
assert result.provider_task_id == "task-xyz"
@@ -10,6 +10,7 @@ from app.core.integrations.image_capabilities import (
clear_image_model_capability_overrides,
register_image_model_capability,
resolve_image_capability,
resolve_image_size,
validate_image_options,
)
@@ -48,3 +49,48 @@ def test_validate_image_options_rejects_capability_mismatch() -> None:
assert "watermark is not supported" in str(exc_info.value)
finally:
clear_image_model_capability_overrides(provider="volcengine")
def test_resolve_image_size_uses_video_reference_ratio_profile() -> None:
clear_image_model_capability_overrides(provider="openai")
register_image_model_capability(
provider="openai",
model_prefix="gpt-image-video-ref",
capability=ImageModelCapability(
supported_ratios={"16:9"},
ratio_size_profiles={"16:9": {"standard": "1792x1024"}},
),
)
try:
size = resolve_image_size(
provider="openai",
model="gpt-image-video-ref-1",
purpose="video_reference",
target_ratio="16:9",
resolution_profile="standard",
requested_size=None,
)
assert size == "1792x1024"
finally:
clear_image_model_capability_overrides(provider="openai")
def test_resolve_image_size_uses_volcengine_builtin_2k_3k_profiles() -> None:
size_2k = resolve_image_size(
provider="volcengine",
model="seedream-v3",
purpose="video_reference",
target_ratio="9:16",
resolution_profile="standard",
requested_size=None,
)
size_3k = resolve_image_size(
provider="volcengine",
model="seedream-v3",
purpose="video_reference",
target_ratio="21:9",
resolution_profile="high",
requested_size=None,
)
assert size_2k == "1600x2848"
assert size_3k == "4704x2016"
@@ -77,7 +77,7 @@ async def test_volcengine_video_create_and_get(monkeypatch: pytest.MonkeyPatch)
_patch_httpx_client(monkeypatch, httpx.MockTransport(handler))
cfg = ProviderConfig(provider="volcengine", api_key="ak-test")
inp = VideoGenerationInput(prompt="舞", size="720x1280", seconds=8, seed=7, watermark=True)
inp = VideoGenerationInput(prompt="舞", ratio="9:16", seconds=8, seed=7, watermark=True)
tid = await VolcengineVideoApiAdapter().create_contents_task(cfg=cfg, input_=inp, timeout_s=30.0)
assert tid == "t-1"
meta = await VolcengineVideoApiAdapter().get_contents_task(cfg=cfg, task_id=tid, timeout_s=30.0)
@@ -86,20 +86,7 @@ async def test_volcengine_video_create_and_get(monkeypatch: pytest.MonkeyPatch)
def test_video_input_seed_bounds_validation() -> None:
VideoGenerationInput(prompt="ok", seed=-1)
VideoGenerationInput(prompt="ok", seed=4294967295)
VideoGenerationInput(prompt="ok", ratio="16:9", seed=-1)
VideoGenerationInput(prompt="ok", ratio="16:9", seed=4294967295)
with pytest.raises(ValidationError):
VideoGenerationInput(prompt="bad", seed=4294967296)
@pytest.mark.asyncio
async def test_openai_video_create_rejects_ratio_size_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
_patch_httpx_client(monkeypatch, httpx.MockTransport(handler))
cfg = ProviderConfig(provider="openai", api_key="sk-test")
inp = VideoGenerationInput(prompt="a cat", ratio="16:9", size="720x1280")
with pytest.raises(ValueError) as exc_info:
await OpenAIVideoApiAdapter().create_video(cfg=cfg, input_=inp, timeout_s=30.0)
assert "ratio conflicts with size" in str(exc_info.value)
VideoGenerationInput(prompt="bad", ratio="16:9", seed=4294967296)
@@ -65,7 +65,7 @@ def test_preview_video_generation_prompt_returns_success_envelope(client: TestCl
"reference_mode": "first_last",
"prompt": "生成一个压迫感强的镜头",
"images": [],
"size": "720x1280",
"ratio": "9:16",
},
)
finally:
@@ -97,6 +97,7 @@ def test_preview_video_generation_prompt_not_found_returns_api_response(
"reference_mode": "text_only",
"prompt": "仅文本生成",
"images": [],
"ratio": "16:9",
},
)
finally:
@@ -125,7 +126,7 @@ def test_create_video_generation_task_returns_created_envelope(client: TestClien
"reference_mode": "first",
"prompt": "生成一个节奏紧张的视频片段",
"images": [],
"size": "720x1280",
"ratio": "9:16",
},
)
finally:
@@ -152,6 +153,7 @@ def test_create_video_generation_task_validation_error_returns_api_response(clie
"reference_mode": "invalid-mode",
"prompt": "bad",
"images": [],
"ratio": "16:9",
},
)
finally:
+78 -102
View File
@@ -172,8 +172,7 @@ async def test_build_run_args_maps_reference_images(monkeypatch: pytest.MonkeyPa
reference_mode="first_last",
prompt="最终视频提示词",
images=["img-first", "img-last"],
size="720x1280",
ratio=None,
ratio="9:16",
)
assert run_args["provider"] == "openai"
@@ -182,6 +181,7 @@ async def test_build_run_args_maps_reference_images(monkeypatch: pytest.MonkeyPa
assert run_args["input"]["first_frame_base64"] == "data:image/png;base64,img-first"
assert run_args["input"]["last_frame_base64"] == "data:image/png;base64,img-last"
assert run_args["input"]["key_frame_base64"] is None
assert run_args["input"]["ratio"] == "9:16"
assert run_args["input"]["seconds"] == 6
await engine.dispose()
@@ -211,11 +211,11 @@ async def test_build_run_args_uses_prompt_pack_when_prompt_missing(monkeypatch:
reference_mode="text_only",
prompt=None,
images=[],
size=None,
ratio=None,
ratio="16:9",
)
assert "镜头标题:镜头一" in run_args["input"]["prompt"]
assert run_args["input"]["ratio"] == "16:9"
assert run_args["prompt_preview"]["shot_id"] == "s1"
await engine.dispose()
@@ -244,8 +244,7 @@ async def test_build_run_args_rejects_disabled_provider() -> None:
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
size=None,
ratio=None,
ratio="16:9",
)
assert exc_info.value.status_code == 503
@@ -299,18 +298,10 @@ async def test_shot_video_readiness_reports_missing_reference_frame() -> None:
@pytest.mark.asyncio
async def test_build_run_args_prefers_shot_override_over_project_default(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_build_run_args_uses_request_ratio_as_final_value(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
project = await db.get(Project, "p1")
assert project is not None
project.default_video_size = "1024x576"
project.default_video_ratio = "16:9"
detail = await db.get(ShotDetail, "s1")
assert detail is not None
detail.override_video_size = "720x1280"
detail.override_video_ratio = "9:16"
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
model = Model(id="m_video", name="sora-mini", category=ModelCategoryKey.video, provider_id="p1")
settings = ModelSettings(id=1, default_video_model_id="m_video")
@@ -331,97 +322,15 @@ async def test_build_run_args_prefers_shot_override_over_project_default(monkeyp
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
size=None,
ratio=None,
ratio="9:16",
)
assert run_args["input"]["size"] == "720x1280"
assert run_args["input"]["ratio"] == "9:16"
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_uses_project_default_when_shot_override_missing(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
project = await db.get(Project, "p1")
assert project is not None
project.default_video_size = "1280x720"
project.default_video_ratio = "16:9"
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
model = Model(id="m_video", name="sora-mini", category=ModelCategoryKey.video, provider_id="p1")
settings = ModelSettings(id=1, default_video_model_id="m_video")
db.add_all([provider, model, settings])
await db.commit()
async def _fake_file_id_to_data_url(_db: AsyncSession, *, file_id: str) -> str:
return f"data:image/png;base64,{file_id}"
monkeypatch.setattr(
"app.services.film.generated_video.file_id_to_data_url",
_fake_file_id_to_data_url,
)
run_args = await build_run_args(
db,
shot_id="s1",
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
size=None,
ratio=None,
)
assert run_args["input"]["size"] == "1280x720"
assert run_args["input"]["ratio"] == "16:9"
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_prefers_request_params_over_inherited(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
project = await db.get(Project, "p1")
assert project is not None
project.default_video_size = "1280x720"
project.default_video_ratio = "16:9"
detail = await db.get(ShotDetail, "s1")
assert detail is not None
detail.override_video_size = "720x1280"
detail.override_video_ratio = "9:16"
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
model = Model(id="m_video", name="sora-mini", category=ModelCategoryKey.video, provider_id="p1")
settings = ModelSettings(id=1, default_video_model_id="m_video")
db.add_all([provider, model, settings])
await db.commit()
async def _fake_file_id_to_data_url(_db: AsyncSession, *, file_id: str) -> str:
return f"data:image/png;base64,{file_id}"
monkeypatch.setattr(
"app.services.film.generated_video.file_id_to_data_url",
_fake_file_id_to_data_url,
)
run_args = await build_run_args(
db,
shot_id="s1",
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
size="1920x1080",
ratio="16:9",
)
assert run_args["input"]["size"] == "1920x1080"
assert run_args["input"]["ratio"] == "16:9"
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_rejects_conflicting_size_and_ratio(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_build_run_args_rejects_missing_ratio(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
@@ -446,9 +355,76 @@ async def test_build_run_args_rejects_conflicting_size_and_ratio(monkeypatch: py
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
size="1920x1080",
ratio="9:16",
ratio=None,
)
assert exc_info.value.status_code == 400
assert "ratio conflicts with size" in str(exc_info.value.detail)
assert exc_info.value.detail == "ratio is required"
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_does_not_read_shot_override_ratio(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
detail = await db.get(ShotDetail, "s1")
assert detail is not None
detail.override_video_ratio = "9:16"
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
model = Model(id="m_video", name="sora-mini", category=ModelCategoryKey.video, provider_id="p1")
settings = ModelSettings(id=1, default_video_model_id="m_video")
db.add_all([provider, model, settings])
await db.commit()
async def _fake_file_id_to_data_url(_db: AsyncSession, *, file_id: str) -> str:
return f"data:image/png;base64,{file_id}"
monkeypatch.setattr(
"app.services.film.generated_video.file_id_to_data_url",
_fake_file_id_to_data_url,
)
run_args = await build_run_args(
db,
shot_id="s1",
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
ratio="16:9",
)
assert run_args["input"]["ratio"] == "16:9"
await engine.dispose()
@pytest.mark.asyncio
async def test_build_run_args_accepts_supported_ratio_without_size(monkeypatch: pytest.MonkeyPatch) -> None:
db, engine = await _build_session()
async with db:
await _seed_shot_graph(db)
provider = Provider(id="p1", name="OpenAI", base_url="https://api.openai.com/v1", api_key="k")
model = Model(id="m_video", name="sora-mini", category=ModelCategoryKey.video, provider_id="p1")
settings = ModelSettings(id=1, default_video_model_id="m_video")
db.add_all([provider, model, settings])
await db.commit()
async def _fake_file_id_to_data_url(_db: AsyncSession, *, file_id: str) -> str:
return f"data:image/png;base64,{file_id}"
monkeypatch.setattr(
"app.services.film.generated_video.file_id_to_data_url",
_fake_file_id_to_data_url,
)
run_args = await build_run_args(
db,
shot_id="s1",
reference_mode="text_only",
prompt="最终视频提示词",
images=[],
ratio="9:16",
)
assert run_args["input"]["ratio"] == "9:16"
await engine.dispose()
@@ -109,7 +109,7 @@ def test_create_shot_frame_image_task_requires_prompt(client: TestClient) -> Non
try:
response = client.post(
"/api/v1/studio/image-tasks/shot/shot-1/frame-image-tasks",
json={"frame_type": "first", "prompt": " ", "images": []},
json={"frame_type": "first", "prompt": " ", "target_ratio": "16:9", "images": []},
)
finally:
app.dependency_overrides.clear()
@@ -190,6 +190,9 @@ def test_create_shot_frame_image_task_renders_prompt_before_submit(client: TestC
assert kwargs["prompt"].startswith("## 图片内容说明")
assert "图1: 陆远" in kwargs["prompt"]
assert kwargs["images"] == [{"image_url": "data:image/png;base64,abc"}]
assert kwargs["target_ratio"] == "9:16"
assert kwargs["resolution_profile"] == "standard"
assert kwargs["purpose"] == "video_reference"
assert kwargs["render_context"]["images"] == ["file-1"]
assert kwargs["render_context"]["mappings"][0]["token"] == "图1"
return "task-1"
@@ -203,6 +206,8 @@ def test_create_shot_frame_image_task_renders_prompt_before_submit(client: TestC
json={
"frame_type": "first",
"prompt": "陆远站在温室里",
"target_ratio": "9:16",
"resolution_profile": "standard",
"images": [
{"type": "character", "id": "char-1", "name": "陆远", "file_id": "file-1"},
],
+85 -7
View File
@@ -1,4 +1,4 @@
"""LLM 接口响应壳测试:聚焦 Provider CRUD。"""
"""LLM 接口响应壳测试:聚焦 Provider CRUD 与生成能力选项。"""
from __future__ import annotations
@@ -9,7 +9,7 @@ import pytest
from fastapi.testclient import TestClient
from app.dependencies import get_db
from app.models.llm import Provider, ProviderStatus
from app.models.llm import Model, ModelCategoryKey, ModelSettings, Provider, ProviderStatus
from tests.support.llm_api_app import build_llm_only_app
# 仅挂载 /api/v1/llm,避免导入 app.main 时连带加载 film 路由与 Celery。
@@ -27,14 +27,29 @@ class _FakeLlmDB:
def __init__(self) -> None:
self.providers: dict[str, Provider] = {}
self.models: dict[str, Model] = {}
self.model_settings: dict[int, ModelSettings] = {}
async def get(self, model: type, entity_id: str) -> Provider | None: # noqa: ANN401
if model is not Provider:
return None
return self.providers.get(entity_id)
if model is Provider:
return self.providers.get(entity_id)
if model is Model:
return self.models.get(entity_id)
if model is ModelSettings:
return self.model_settings.get(int(entity_id))
return None
def add(self, obj: Provider) -> None:
self.providers[obj.id] = obj
def add(self, obj: Provider | Model | ModelSettings) -> None:
if isinstance(obj, Provider):
self.providers[obj.id] = obj
return
if isinstance(obj, Model):
self.models[obj.id] = obj
return
if isinstance(obj, ModelSettings):
self.model_settings[obj.id] = obj
return
raise TypeError(f"Unsupported object type: {type(obj)!r}")
async def flush(self) -> None:
return None
@@ -67,6 +82,30 @@ def _seed_provider(db: _FakeLlmDB, provider_id: str = "p-1") -> Provider:
return obj
def _seed_video_model(db: _FakeLlmDB, *, provider_id: str = "p-video", model_id: str = "m-video") -> None:
provider = _seed_provider(db, provider_id)
provider.name = "OpenAI"
db.models[model_id] = Model(
id=model_id,
name="sora-mini",
category=ModelCategoryKey.video,
provider_id=provider.id,
)
db.model_settings[1] = ModelSettings(id=1, default_video_model_id=model_id)
def _seed_image_model(db: _FakeLlmDB, *, provider_id: str = "p-image", model_id: str = "m-image") -> None:
provider = _seed_provider(db, provider_id)
provider.name = "火山引擎"
db.models[model_id] = Model(
id=model_id,
name="seedream-4.0",
category=ModelCategoryKey.image,
provider_id=provider.id,
)
db.model_settings[1] = ModelSettings(id=1, default_image_model_id=model_id)
def _override_db(db: _FakeLlmDB):
async def _get_db() -> AsyncGenerator[_FakeLlmDB, None]:
yield db
@@ -185,3 +224,42 @@ def test_list_supported_providers_can_filter_by_category(client: TestClient) ->
assert isinstance(body["data"], list)
for item in body["data"]:
assert "video" in item["supported_categories"]
def test_get_video_generation_options_returns_ratio_capability(client: TestClient) -> None:
db = _FakeLlmDB()
_seed_video_model(db)
llm_app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.get("/api/v1/llm/video-generation-options")
finally:
llm_app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["code"] == 200
assert body["data"]["provider"] == "openai"
assert body["data"]["model_id"] == "m-video"
assert body["data"]["model_name"] == "sora-mini"
assert "16:9" in body["data"]["allowed_ratios"]
assert body["data"]["default_ratio"] == "16:9"
def test_get_image_generation_options_returns_ratio_size_profiles(client: TestClient) -> None:
db = _FakeLlmDB()
_seed_image_model(db)
llm_app.dependency_overrides[get_db] = _override_db(db)
try:
response = client.get("/api/v1/llm/image-generation-options")
finally:
llm_app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["code"] == 200
assert body["data"]["provider"] == "volcengine"
assert body["data"]["model_id"] == "m-image"
assert body["data"]["model_name"] == "seedream-4.0"
assert body["data"]["default_resolution_profile"] == "standard"
assert body["data"]["ratio_size_profiles"]["16:9"]["standard"] == "2848x1600"
assert body["data"]["ratio_size_profiles"]["16:9"]["high"] == "4096x2304"
+38
View File
@@ -11,6 +11,7 @@ from app.schemas.llm import ModelCreate, ModelSettingsUpdate, ModelUpdate, Provi
from app.services.llm.manage import (
create_model,
create_provider,
get_image_generation_options,
get_or_create_settings,
list_models_paginated,
update_model,
@@ -211,3 +212,40 @@ async def test_update_model_rejects_switch_to_unsupported_provider_category_comb
assert exc_info.value.status_code == 400
assert "does not support category=video" in str(exc_info.value.detail)
await engine.dispose()
@pytest.mark.asyncio
async def test_get_image_generation_options_uses_default_image_model_capability() -> None:
db, engine = await _build_session()
async with db:
await create_provider(
db,
body=ProviderCreate(
id="p-volc",
name="火山引擎",
base_url="https://ark.cn-beijing.volces.com/api/v3",
api_key="k",
),
)
await create_model(
db,
body=ModelCreate(
id="m-image-default",
name="seedream-4.0",
category=ModelCategoryKey.image,
provider_id="p-volc",
),
)
await update_model_settings(
db,
body=ModelSettingsUpdate(default_image_model_id="m-image-default"),
)
options = await get_image_generation_options(db)
assert options.provider == "volcengine"
assert options.model_id == "m-image-default"
assert options.default_resolution_profile == "standard"
assert options.ratio_size_profiles["9:16"]["standard"] == "1600x2848"
assert options.ratio_size_profiles["21:9"]["high"] == "4704x2016"
await engine.dispose()
+9 -3
View File
@@ -70,6 +70,13 @@ class _FakeStudioDB:
return
raise TypeError(f"Unsupported object type: {type(obj)!r}")
async def execute(self, _stmt): # noqa: ANN001
class _EmptyResult:
def all(self) -> list[tuple]:
return []
return _EmptyResult()
def _override_db(db: _FakeStudioDB):
async def _get_db() -> AsyncGenerator[_FakeStudioDB, None]:
@@ -181,9 +188,8 @@ def test_project_style_options_returns_grouped_choices(client: TestClient) -> No
assert "视觉" in body["data"]["visual_styles"][0]["label"] or body["data"]["visual_styles"][0]["value"] in {"现实", "动漫"}
assert "现实" in body["data"]["styles_by_visual_style"]
assert "动漫" in body["data"]["styles_by_visual_style"]
assert "16:9" in [item["value"] for item in body["data"]["video_ratios"]]
assert body["data"]["default_video_ratio"] == "16:9"
assert body["data"]["default_video_size"] == "1920x1080"
assert "video_ratios" not in body["data"]
assert "default_video_ratio" not in body["data"]
def test_create_project_rejects_invalid_style_combo(client: TestClient) -> None:
+1 -1
View File
@@ -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/HTTPValidatioLine truncated
{"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/HTTPValidatioLine truncated
@@ -60,6 +60,7 @@ import {
import { useLocation, useParams, Link } from 'react-router-dom'
import {
FilmService,
LlmService,
StudioChaptersService,
StudioEntitiesService,
StudioFilesService,
@@ -79,6 +80,7 @@ import type {
CameraShotType,
ChapterRead,
EntityNameExistenceItem,
ImageGenerationOptionsRead,
ProjectActorLinkRead,
ProjectCostumeLinkRead,
ShotDetailRead,
@@ -110,6 +112,7 @@ import { useGenerationDraft, type GenerationDraftState } from '../hooks/useGener
import { useTaskPageContext } from '../components/taskPageContext'
import type { RelationTaskState } from '../project/ProjectWorkbench/chapterDivisionTasks'
import { toRelationTaskStateFromStatusRead } from '../project/ProjectWorkbench/chapterDivisionTasks'
import { useProjectStyleOptions } from '../project/useProjectStyleOptions'
import './chapterStudio.separation.css'
const { Sider, Content } = Layout
@@ -192,6 +195,8 @@ type KeyframeCardState = {
applyingFileId: string | null
}
type KeyframeResolutionProfile = 'standard' | 'high'
type InspectorTabKey = 'ops' | 'camera' | 'prompt_image' | 'dialogue' | 'keyframe_gen' | 'gen_ref'
const LAYOUT_STORAGE_KEY = 'jellyfish_chapter_studio_layout_v1'
@@ -205,6 +210,22 @@ function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function getResolutionProfileLabel(profile: KeyframeResolutionProfile): string {
return profile === 'high' ? '高清(3K)' : '标准(2K)'
}
function resolveKeyframePixelSize(
options: ImageGenerationOptionsRead | null,
ratio: string,
profile: KeyframeResolutionProfile,
): string {
const normalizedRatio = String(ratio ?? '').trim()
if (!options || !normalizedRatio) return ''
const profiles = options.ratio_size_profiles?.[normalizedRatio] ?? null
if (!profiles) return ''
return profiles[profile] ?? profiles.standard ?? ''
}
function mapGenerationDraftStateToRenderState(
state: GenerationDraftState,
): 'idle' | 'stale' | 'syncing' | 'clean' | 'error' {
@@ -395,6 +416,9 @@ const ChapterStudio: React.FC = () => {
const [chapter, setChapter] = useState<Chapter | null>(null)
const [projectVisualStyle, setProjectVisualStyle] = useState<'现实' | '动漫'>('现实')
const [projectStyle, setProjectStyle] = useState<string>('真人都市')
const [projectDefaultVideoRatio, setProjectDefaultVideoRatio] = useState<string>('')
const { videoRatioOptions, defaultVideoRatio: capabilityDefaultVideoRatio } = useProjectStyleOptions()
const [imageGenerationOptions, setImageGenerationOptions] = useState<ImageGenerationOptionsRead | null>(null)
const [shots, setShots] = useState<StudioShot[]>([])
const [shotRuntimeMap, setShotRuntimeMap] = useState<Record<string, ShotRuntimeState>>({})
const [selectedShotId, setSelectedShotId] = useState<string | null>(null)
@@ -430,6 +454,7 @@ const ChapterStudio: React.FC = () => {
const [promptAssetsUpdating, setPromptAssetsUpdating] = useState(false)
const [frameTab, setFrameTab] = useState<'head' | 'keyframes' | 'tail' | 'compare'>('keyframes')
const [keyframeResolutionProfile, setKeyframeResolutionProfile] = useState<KeyframeResolutionProfile>('standard')
const [frameFileTagsMap, setFrameFileTagsMap] = useState<Record<string, string[]>>({})
const [frameFileTagsLoading, setFrameFileTagsLoading] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1)
@@ -447,6 +472,23 @@ const ChapterStudio: React.FC = () => {
const [dragOverShotId, setDragOverShotId] = useState<string | null>(null)
const [isResizing, setIsResizing] = useState(false)
useEffect(() => {
let active = true
void (async () => {
try {
const res = await LlmService.getImageGenerationOptionsApiV1LlmImageGenerationOptionsGet()
if (!active) return
setImageGenerationOptions(res.data ?? null)
} catch {
if (!active) return
setImageGenerationOptions(null)
}
})()
return () => {
active = false
}
}, [])
const containerRef = useRef<HTMLDivElement | null>(null)
const dragStateRef = useRef<null | { type: 'left' | 'right'; startX: number; startLeft: number; startRight: number }>(null)
const resizeRafRef = useRef<number | null>(null)
@@ -454,6 +496,15 @@ const ChapterStudio: React.FC = () => {
const showPreviewMinimizeButton = false
const showPreviewFrameSegmented = false
const showChapterTimeline = false
const resolveShotVideoRatio = useCallback(
(detail?: ShotDetailRead | null) => {
const shotRatio = String(detail?.override_video_ratio ?? '').trim()
const projectRatio = String(projectDefaultVideoRatio ?? '').trim()
const fallbackRatio = String(capabilityDefaultVideoRatio ?? '').trim()
return shotRatio || projectRatio || fallbackRatio
},
[capabilityDefaultVideoRatio, projectDefaultVideoRatio],
)
const hiddenKey = useMemo(() => (chapterId ? `jellyfish_hidden_shots_${chapterId}` : null), [chapterId])
const hiddenIds = useMemo(() => {
@@ -621,15 +672,18 @@ const ChapterStudio: React.FC = () => {
setChapter(toUIChapter(data))
const nextVisualStyle = projectRes?.data?.visual_style
const nextStyle = projectRes?.data?.style
const nextDefaultRatio = typeof projectRes?.data?.default_video_ratio === 'string' ? projectRes.data.default_video_ratio : ''
if (nextVisualStyle === '现实' || nextVisualStyle === '动漫') {
setProjectVisualStyle(nextVisualStyle)
}
if (typeof nextStyle === 'string' && nextStyle.trim()) {
setProjectStyle(nextStyle)
}
setProjectDefaultVideoRatio(nextDefaultRatio)
} catch {
// 章节信息仅用于标题展示,失败不阻断工作台
setChapter(null)
setProjectDefaultVideoRatio('')
}
}
@@ -1037,13 +1091,22 @@ const ChapterStudio: React.FC = () => {
: null
})
.filter(Boolean)
const targetRatio = resolveShotVideoRatio(d)
if (!targetRatio) continue
await StudioImageTasksService.createShotFrameImageGenerationTaskApiV1StudioImageTasksShotShotIdFrameImageTasksPost({
shotId: id,
requestBody: { frame_type: target.frame_type as any, model_id: null, prompt, images: imagesPayload } as any,
requestBody: {
frame_type: target.frame_type as any,
model_id: null,
prompt,
images: imagesPayload,
target_ratio: targetRatio,
resolution_profile: keyframeResolutionProfile,
} as any,
})
}
},
[],
[keyframeResolutionProfile, resolveShotVideoRatio],
)
const updatePromptProps = async (propIds: string[]) => {
@@ -1205,9 +1268,21 @@ const ChapterStudio: React.FC = () => {
: null
})
.filter(Boolean)
const targetRatio = resolveShotVideoRatio(shotDetail)
if (!targetRatio) {
message.warning('请先设置视频比例')
return
}
await StudioImageTasksService.createShotFrameImageGenerationTaskApiV1StudioImageTasksShotShotIdFrameImageTasksPost({
shotId: selectedShotId,
requestBody: { frame_type: target.frame_type as any, model_id: null, prompt, images: imagesPayload } as any,
requestBody: {
frame_type: target.frame_type as any,
model_id: null,
prompt,
images: imagesPayload,
target_ratio: targetRatio,
resolution_profile: keyframeResolutionProfile,
} as any,
})
message.success('已创建生成任务')
} catch {
@@ -1344,6 +1419,7 @@ const ChapterStudio: React.FC = () => {
assignIfChanged('atmosphere')
assignIfChanged('follow_atmosphere')
assignIfChanged('has_bgm')
assignIfChanged('override_video_ratio')
assignIfChanged('vfx_type')
assignIfChanged('vfx_note')
assignIfChanged('first_frame_prompt')
@@ -2525,10 +2601,16 @@ const ChapterStudio: React.FC = () => {
}}
>
<Inspector
projectId={projectId}
projectId={projectId}
chapterId={chapterId}
projectVisualStyle={projectVisualStyle}
projectStyle={projectStyle}
projectDefaultVideoRatio={projectDefaultVideoRatio}
capabilityDefaultVideoRatio={capabilityDefaultVideoRatio}
videoRatioOptions={videoRatioOptions}
imageGenerationOptions={imageGenerationOptions}
keyframeResolutionProfile={keyframeResolutionProfile}
onChangeKeyframeResolutionProfile={setKeyframeResolutionProfile}
loadingDetail={loadingDetail}
shotDetail={shotDetail}
dialogLines={dialogLines}
@@ -2597,6 +2679,12 @@ const ChapterStudio: React.FC = () => {
chapterId={chapterId}
projectVisualStyle={projectVisualStyle}
projectStyle={projectStyle}
projectDefaultVideoRatio={projectDefaultVideoRatio}
capabilityDefaultVideoRatio={capabilityDefaultVideoRatio}
videoRatioOptions={videoRatioOptions}
imageGenerationOptions={imageGenerationOptions}
keyframeResolutionProfile={keyframeResolutionProfile}
onChangeKeyframeResolutionProfile={setKeyframeResolutionProfile}
loadingDetail={loadingDetail}
shotDetail={shotDetail}
dialogLines={dialogLines}
@@ -2735,6 +2823,12 @@ function Inspector(props: {
chapterId?: string
projectVisualStyle: '现实' | '动漫'
projectStyle: string
projectDefaultVideoRatio: string
capabilityDefaultVideoRatio: string
videoRatioOptions: Array<{ value: string; label: React.ReactNode }>
imageGenerationOptions: ImageGenerationOptionsRead | null
keyframeResolutionProfile: KeyframeResolutionProfile
onChangeKeyframeResolutionProfile: (value: KeyframeResolutionProfile) => void
loadingDetail: boolean
shotDetail: ShotDetailRead | null
dialogLines: ShotDialogLineRead[]
@@ -2768,6 +2862,12 @@ function Inspector(props: {
chapterId,
projectVisualStyle,
projectStyle,
projectDefaultVideoRatio,
capabilityDefaultVideoRatio,
videoRatioOptions,
imageGenerationOptions,
keyframeResolutionProfile,
onChangeKeyframeResolutionProfile,
loadingDetail,
shotDetail,
dialogLines,
@@ -2855,6 +2955,18 @@ function Inspector(props: {
const [videoPromptPreviewOpen, setVideoPromptPreviewOpen] = useState(false)
const [videoPromptPreviewLoading, setVideoPromptPreviewLoading] = useState(false)
const [videoPromptPreviewSubmitting, setVideoPromptPreviewSubmitting] = useState(false)
const resolveVideoRatioForRequest = useCallback(() => {
const shotRatio = String(shotDetail?.override_video_ratio ?? '').trim()
const projectRatio = String(projectDefaultVideoRatio ?? '').trim()
const fallbackRatio = String(capabilityDefaultVideoRatio ?? '').trim()
return shotRatio || projectRatio || fallbackRatio
}, [capabilityDefaultVideoRatio, projectDefaultVideoRatio, shotDetail?.override_video_ratio])
const resolvedKeyframeRatio = resolveVideoRatioForRequest()
const resolvedKeyframePixelSize = resolveKeyframePixelSize(
imageGenerationOptions,
resolvedKeyframeRatio,
keyframeResolutionProfile,
)
const videoPromptDraft = useGenerationDraft<
{ prompt: string },
{ referenceMode: VideoReferenceMode; images: string[] },
@@ -2867,12 +2979,17 @@ function Inspector(props: {
if (!selectedShot?.id) {
throw new Error('shot is required')
}
const ratio = resolveVideoRatioForRequest()
if (!ratio) {
throw new Error('video ratio is required')
}
const res = await FilmService.previewVideoGenerationPromptApiV1FilmTasksVideoPreviewPromptPost({
requestBody: {
shot_id: selectedShot.id,
reference_mode: context.referenceMode,
prompt: (base.prompt || '').trim() || null,
images: context.images,
ratio,
} as any,
})
const data = res.data as any
@@ -2885,12 +3002,17 @@ function Inspector(props: {
if (!selectedShot?.id) {
throw new Error('shot is required')
}
const ratio = resolveVideoRatioForRequest()
if (!ratio) {
throw new Error('video ratio is required')
}
const created = await FilmService.createVideoGenerationTaskApiV1FilmTasksVideoPost({
requestBody: {
shot_id: selectedShot.id,
reference_mode: context.referenceMode,
prompt: (derived.prompt || '').trim(),
images: derived.images,
ratio,
} as any,
})
return {
@@ -3590,6 +3712,10 @@ function Inspector(props: {
}
})
const ratio = resolveVideoRatioForRequest()
if (!ratio) {
throw new Error('video ratio is required')
}
const created = await StudioImageTasksService.createShotFrameImageGenerationTaskApiV1StudioImageTasksShotShotIdFrameImageTasksPost({
shotId: selectedShot.id,
requestBody: {
@@ -3597,6 +3723,8 @@ function Inspector(props: {
model_id: null,
prompt: (base.prompt || '').trim(),
images: resolvedItems as any,
target_ratio: ratio,
resolution_profile: keyframeResolutionProfile,
} as any,
})
return {
@@ -3652,6 +3780,7 @@ function Inspector(props: {
},
[
keyframePromptDraft,
keyframeResolutionProfile,
keyframePromptPreviewDraft,
keyframePromptPreviewFrameType,
keyframePromptPreviewRefFileIds,
@@ -4020,6 +4149,10 @@ function Inspector(props: {
message.warning('请先选择一个分镜')
return
}
if (!resolveVideoRatioForRequest()) {
message.warning('当前镜头缺少视频比例,请先设置项目默认比例或镜头覆盖比例')
return
}
const prompt = (videoPromptPreviewDraft || '').trim()
if (!prompt) {
message.warning('请输入视频提示词')
@@ -4553,6 +4686,23 @@ function Inspector(props: {
/>
</div>
</div>
<div>
<div className="text-gray-500 text-xs mb-1">视频比例</div>
<Select
size="small"
allowClear
value={shotDetail.override_video_ratio ?? undefined}
placeholder={projectDefaultVideoRatio || capabilityDefaultVideoRatio || '请选择视频比例'}
options={videoRatioOptions}
onChange={(value) => {
onPatchShotDetail({ override_video_ratio: value ?? null })
}}
disabled={cameraUpdating}
/>
<div className="mt-1 text-[11px] text-gray-400">
当前生效:{resolveVideoRatioForRequest() || '未设置'}
</div>
</div>
</div>
</div>
@@ -4714,6 +4864,38 @@ function Inspector(props: {
label: '关键帧与参考图',
children: (
<div className="space-y-3">
<div className="cs-group">
<div className="cs-group-title">
<SettingOutlined /> 关键帧规格
</div>
<div className="text-xs text-gray-500 mb-2">
关键帧会跟随当前视频比例生成;这里控制参考帧分辨率档位。
</div>
<div className="flex items-center gap-3">
<div className="min-w-[96px] text-xs text-gray-500">分辨率档位</div>
<Select
size="small"
value={keyframeResolutionProfile}
style={{ width: 160 }}
options={[
{ value: 'standard', label: '标准(2K)' },
{ value: 'high', label: '高清(3K)' },
]}
onChange={(value) => onChangeKeyframeResolutionProfile(value as KeyframeResolutionProfile)}
/>
</div>
<div className="mt-2 rounded bg-gray-50 px-3 py-2 text-xs text-gray-600">
<div>
当前规格:{resolvedKeyframeRatio || '未设置比例'} ·{' '}
{getResolutionProfileLabel(keyframeResolutionProfile)}
{resolvedKeyframePixelSize ? ` → ${resolvedKeyframePixelSize}` : ''}
</div>
<div className="mt-1 text-gray-500">
当前模型:{imageGenerationOptions?.provider || '未识别供应商'}
{imageGenerationOptions?.model_name ? ` / ${imageGenerationOptions.model_name}` : ''}
</div>
</div>
</div>
{(['first', 'key', 'last'] as PromptFrameType[]).map((ft) => {
const st = keyframeCards[ft]
const slot = frameImages.find((x) => x.frame_type === ft)
@@ -56,7 +56,6 @@ type ProjectStageSummary = {
type ProjectFlowStatsMap = Record<string, ProjectFlowStats>
type ProjectView = Project & {
visualStyle?: ProjectVisualStyleChoice
defaultVideoSize?: string | null
defaultVideoRatio?: string | null
}
@@ -79,9 +78,7 @@ const ProjectLobby: React.FC = () => {
const [projectFlowStatsMap, setProjectFlowStatsMap] = useState<ProjectFlowStatsMap>({})
const {
options: projectStyleOptions,
videoSizeOptions,
videoRatioOptions,
defaultVideoSize,
defaultVideoRatio,
} = useProjectStyleOptions()
const [form] = Form.useForm()
@@ -117,7 +114,6 @@ const ProjectLobby: React.FC = () => {
},
updatedAt,
visualStyle: (p.visual_style as ProjectVisualStyleChoice | undefined) ?? '现实',
defaultVideoSize: p.default_video_size ?? null,
defaultVideoRatio: p.default_video_ratio ?? null,
}
}
@@ -382,7 +378,6 @@ const ProjectLobby: React.FC = () => {
style: defaultStyle,
seed: Math.floor(Math.random() * 99999),
unifyStyle: true,
default_video_size: defaultVideoSize,
default_video_ratio: defaultVideoRatio,
})
setCreateModalOpen(true)
@@ -395,7 +390,6 @@ const ProjectLobby: React.FC = () => {
visual_style: ProjectVisualStyleChoice
seed: number
unifyStyle: boolean
default_video_size?: string
default_video_ratio?: string
}) => {
try {
@@ -409,7 +403,6 @@ const ProjectLobby: React.FC = () => {
visual_style: values.visual_style as any,
seed: values.seed,
unify_style: values.unifyStyle,
default_video_size: values.default_video_size || null,
default_video_ratio: values.default_video_ratio || null,
progress: 0,
},
@@ -436,7 +429,6 @@ const ProjectLobby: React.FC = () => {
visual_style: p.visualStyle ?? '现实',
seed: p.seed,
unifyStyle: p.unifyStyle,
default_video_size: p.defaultVideoSize ?? undefined,
default_video_ratio: p.defaultVideoRatio ?? undefined,
})
setEditModalOpen(true)
@@ -449,7 +441,6 @@ const ProjectLobby: React.FC = () => {
visual_style: ProjectVisualStyleChoice
seed: number
unifyStyle: boolean
default_video_size?: string
default_video_ratio?: string
}) => {
if (!editingProject) return
@@ -463,7 +454,6 @@ const ProjectLobby: React.FC = () => {
visual_style: values.visual_style as any,
seed: values.seed,
unify_style: values.unifyStyle,
default_video_size: values.default_video_size || null,
default_video_ratio: values.default_video_ratio || null,
},
})
@@ -982,7 +972,6 @@ const ProjectLobby: React.FC = () => {
'真人都市',
seed: Math.floor(Math.random() * 99999),
unifyStyle: true,
default_video_size: defaultVideoSize,
default_video_ratio: defaultVideoRatio,
}}
>
@@ -1004,9 +993,6 @@ const ProjectLobby: React.FC = () => {
>
<InputNumber min={0} className="w-full" />
</Form.Item>
<Form.Item name="default_video_size" label="默认视频尺寸">
<Select allowClear placeholder="未设置时由模型/供应商决定" options={videoSizeOptions} />
</Form.Item>
<Form.Item name="default_video_ratio" label="默认视频比例">
<Select allowClear placeholder="未设置时由模型/供应商决定" options={videoRatioOptions} />
</Form.Item>
@@ -1052,9 +1038,6 @@ const ProjectLobby: React.FC = () => {
<Form.Item name="seed" label="全局种子值" tooltip="固定种子可确保整部短剧视觉调性一致">
<InputNumber min={0} className="w-full" />
</Form.Item>
<Form.Item name="default_video_size" label="默认视频尺寸">
<Select allowClear placeholder="未设置时由模型/供应商决定" options={videoSizeOptions} />
</Form.Item>
<Form.Item name="default_video_ratio" label="默认视频比例">
<Select allowClear placeholder="未设置时由模型/供应商决定" options={videoRatioOptions} />
</Form.Item>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { StudioProjectsService } from '../../../services/generated'
import { LlmService, StudioProjectsService } from '../../../services/generated'
import type { ProjectStyleOptionsRead } from '../../../services/generated'
import type { ProjectStyleFieldOptions } from './ProjectVisualStyleAndStyleFields'
@@ -30,15 +30,12 @@ const FALLBACK_OPTIONS: ProjectStyleFieldOptions = {
type OptionItem = { value: string; label: string }
type ProjectStyleOptionsSnapshot = {
options: ProjectStyleFieldOptions
videoSizeOptions: OptionItem[]
videoRatioOptions: OptionItem[]
defaultVideoSize: string
defaultVideoRatio: string
}
let cachedSnapshot: ProjectStyleOptionsSnapshot | null = null
let loadingSnapshotPromise: Promise<ProjectStyleOptionsSnapshot> | null = null
const FALLBACK_DEFAULT_VIDEO_SIZE = '1920x1080'
const FALLBACK_DEFAULT_VIDEO_RATIO = '16:9'
function normalizeOptionItems(items: OptionItem[] | null | undefined): OptionItem[] {
@@ -79,23 +76,25 @@ async function loadProjectStyleOptionsSnapshot(): Promise<ProjectStyleOptionsSna
if (loadingSnapshotPromise) return loadingSnapshotPromise
loadingSnapshotPromise = (async () => {
try {
const res = await StudioProjectsService.getProjectStyleOptionsApiV1StudioProjectsStyleOptionsGet()
const data = res.data
const [styleRes, videoRes] = await Promise.all([
StudioProjectsService.getProjectStyleOptionsApiV1StudioProjectsStyleOptionsGet(),
LlmService.getVideoGenerationOptionsApiV1LlmVideoGenerationOptionsGet(),
])
const styleData = styleRes.data
const videoData = videoRes.data
const snapshot: ProjectStyleOptionsSnapshot = {
options: normalizeStyleOptions(data ?? undefined),
videoSizeOptions: normalizeOptionItems((data?.video_sizes ?? []) as OptionItem[]),
videoRatioOptions: normalizeOptionItems((data?.video_ratios ?? []) as OptionItem[]),
defaultVideoSize: data?.default_video_size ?? FALLBACK_DEFAULT_VIDEO_SIZE,
defaultVideoRatio: data?.default_video_ratio ?? FALLBACK_DEFAULT_VIDEO_RATIO,
options: normalizeStyleOptions(styleData ?? undefined),
videoRatioOptions: normalizeOptionItems(
(videoData?.allowed_ratios ?? []).map((value) => ({ value, label: value })),
),
defaultVideoRatio: videoData?.default_ratio ?? FALLBACK_DEFAULT_VIDEO_RATIO,
}
cachedSnapshot = snapshot
return snapshot
} catch {
const snapshot: ProjectStyleOptionsSnapshot = {
options: FALLBACK_OPTIONS,
videoSizeOptions: [],
videoRatioOptions: [],
defaultVideoSize: FALLBACK_DEFAULT_VIDEO_SIZE,
defaultVideoRatio: FALLBACK_DEFAULT_VIDEO_RATIO,
}
cachedSnapshot = snapshot
@@ -109,9 +108,7 @@ async function loadProjectStyleOptionsSnapshot(): Promise<ProjectStyleOptionsSna
export function useProjectStyleOptions() {
const [options, setOptions] = useState<ProjectStyleFieldOptions>(cachedSnapshot?.options ?? FALLBACK_OPTIONS)
const [videoSizeOptions, setVideoSizeOptions] = useState<OptionItem[]>(cachedSnapshot?.videoSizeOptions ?? [])
const [videoRatioOptions, setVideoRatioOptions] = useState<OptionItem[]>(cachedSnapshot?.videoRatioOptions ?? [])
const [defaultVideoSize, setDefaultVideoSize] = useState<string>(cachedSnapshot?.defaultVideoSize ?? FALLBACK_DEFAULT_VIDEO_SIZE)
const [defaultVideoRatio, setDefaultVideoRatio] = useState<string>(cachedSnapshot?.defaultVideoRatio ?? FALLBACK_DEFAULT_VIDEO_RATIO)
useEffect(() => {
@@ -120,9 +117,7 @@ export function useProjectStyleOptions() {
const snapshot = await loadProjectStyleOptionsSnapshot()
if (!active) return
setOptions(snapshot.options)
setVideoSizeOptions(snapshot.videoSizeOptions)
setVideoRatioOptions(snapshot.videoRatioOptions)
setDefaultVideoSize(snapshot.defaultVideoSize)
setDefaultVideoRatio(snapshot.defaultVideoRatio)
})()
return () => {
@@ -138,9 +133,7 @@ export function useProjectStyleOptions() {
return {
options,
videoSizeOptions,
videoRatioOptions,
defaultVideoSize,
defaultVideoRatio,
defaultVisualStyle,
getDefaultStyle,
+4
View File
@@ -18,6 +18,7 @@ export type { ApiResponse_EntityNameExistenceCheckResponse_ } from './models/Api
export type { ApiResponse_FileDetailRead_ } from './models/ApiResponse_FileDetailRead_';
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_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__';
@@ -75,6 +76,7 @@ export type { ApiResponse_TaskLinkAdoptRead_ } from './models/ApiResponse_TaskLi
export type { ApiResponse_TaskResultRead_ } from './models/ApiResponse_TaskResultRead_';
export type { ApiResponse_TaskStatusRead_ } from './models/ApiResponse_TaskStatusRead_';
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 { 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';
@@ -111,6 +113,7 @@ export type { GenerationTaskLinkCreate } from './models/GenerationTaskLinkCreate
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 { LogLevel } from './models/LogLevel';
export type { ModelCategoryKey } from './models/ModelCategoryKey';
export type { ModelCreate } from './models/ModelCreate';
@@ -238,6 +241,7 @@ export type { VariantAnalysisRequest } from './models/VariantAnalysisRequest';
export type { VariantAnalysisResult } from './models/VariantAnalysisResult';
export type { VariantSuggestion } from './models/VariantSuggestion';
export type { VFXType } from './models/VFXType';
export type { VideoGenerationOptionsRead } from './models/VideoGenerationOptionsRead';
export type { VideoGenerationTaskRequest } from './models/VideoGenerationTaskRequest';
export type { VideoPromptPreviewResponse } from './models/VideoPromptPreviewResponse';
@@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ImageGenerationOptionsRead } from './ImageGenerationOptionsRead';
export type ApiResponse_ImageGenerationOptionsRead_ = {
/**
* 与 HTTP 状态码一致
*/
code?: number;
/**
* 提示信息
*/
message?: string;
/**
* 实际数据
*/
data?: (ImageGenerationOptionsRead | null);
/**
* 附加元信息
*/
meta?: (Record<string, any> | null);
};
@@ -0,0 +1,24 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { VideoGenerationOptionsRead } from './VideoGenerationOptionsRead';
export type ApiResponse_VideoGenerationOptionsRead_ = {
/**
* 与 HTTP 状态码一致
*/
code?: number;
/**
* 提示信息
*/
message?: string;
/**
* 实际数据
*/
data?: (VideoGenerationOptionsRead | null);
/**
* 附加元信息
*/
meta?: (Record<string, any> | null);
};
@@ -0,0 +1,34 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* 当前默认图片模型对应的关键帧规格选项。
*/
export type ImageGenerationOptionsRead = {
/**
* 供应商稳定键
*/
provider: string;
/**
* 默认图片模型 ID
*/
model_id: string;
/**
* 默认图片模型名称
*/
model_name: string;
/**
* 当前模型支持的目标比例
*/
supported_ratios?: Array<string>;
/**
* 当前模型默认分辨率档位
*/
default_resolution_profile: string;
/**
* 按比例和分辨率档位映射得到的像素尺寸
*/
ratio_size_profiles?: Record<string, Record<string, string>>;
};
@@ -33,10 +33,6 @@ export type ProjectCreate = {
* 进度百分比(0-100)
*/
progress?: number;
/**
* 项目级默认视频尺寸;分镜未覆盖时生效
*/
default_video_size?: (string | null);
/**
* 项目级默认视频比例;分镜未覆盖时生效
*/
@@ -33,10 +33,6 @@ export type ProjectRead = {
* 进度百分比(0-100)
*/
progress?: number;
/**
* 项目级默认视频尺寸;分镜未覆盖时生效
*/
default_video_size?: (string | null);
/**
* 项目级默认视频比例;分镜未覆盖时生效
*/
@@ -4,7 +4,7 @@
/* eslint-disable */
import type { StyleOption } from './StyleOption';
/**
* 项目风格与视频参数候选项。
* 项目风格候选项。
*/
export type ProjectStyleOptionsRead = {
/**
@@ -19,21 +19,5 @@ export type ProjectStyleOptionsRead = {
* 各视觉风格默认视频风格
*/
default_style_by_visual_style?: Record<string, string>;
/**
* 默认视频比例(用于创建项目时的初始值)
*/
default_video_ratio?: (string | null);
/**
* 默认视频尺寸(用于创建项目时的初始值)
*/
default_video_size?: (string | null);
/**
* 视频比例候选项
*/
video_ratios?: Array<StyleOption>;
/**
* 视频尺寸候选项
*/
video_sizes?: Array<StyleOption>;
};
@@ -15,7 +15,6 @@ export type ProjectUpdate = {
seed?: (number | null);
unify_style?: (boolean | null);
progress?: (number | null);
default_video_size?: (string | null);
default_video_ratio?: (string | null);
stats?: (Record<string, any> | null);
};
@@ -31,10 +31,6 @@ export type ShotDetailCreate = {
* 时长(秒)
*/
duration?: number;
/**
* 分镜级视频尺寸覆盖;为空表示继承项目默认
*/
override_video_size?: (string | null);
/**
* 分镜级视频比例覆盖;为空表示继承项目默认
*/
@@ -31,10 +31,6 @@ export type ShotDetailRead = {
* 时长(秒)
*/
duration?: number;
/**
* 分镜级视频尺寸覆盖;为空表示继承项目默认
*/
override_video_size?: (string | null);
/**
* 分镜级视频比例覆盖;为空表示继承项目默认
*/
@@ -12,7 +12,6 @@ export type ShotDetailUpdate = {
movement?: (CameraMovement | null);
scene_id?: (string | null);
duration?: (number | null);
override_video_size?: (string | null);
override_video_ratio?: (string | null);
mood_tags?: (Array<string> | null);
atmosphere?: (string | null);
@@ -26,5 +26,13 @@ export type ShotFrameImageTaskRequest = {
* 参考资产条目列表(可多张,顺序有效)。后端会使用 item.file_id 作为参考图;无效条目会被跳过。
*/
images?: Array<ShotLinkedAssetItem>;
/**
* 目标视频画幅比例;关键帧将按该画幅生成,以提升后续视频参考稳定性
*/
target_ratio: '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' | '3:2' | '2:3';
/**
* 关键帧输出分辨率档位,默认 standard
*/
resolution_profile?: ('standard' | 'high' | null);
};
@@ -0,0 +1,30 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
/**
* 当前默认视频模型对应的生成参数选项。
*/
export type VideoGenerationOptionsRead = {
/**
* 供应商稳定键
*/
provider: string;
/**
* 默认视频模型 ID
*/
model_id: string;
/**
* 默认视频模型名称
*/
model_name: string;
/**
* 当前模型允许的比例选项
*/
allowed_ratios?: Array<string>;
/**
* 当前模型默认比例
*/
default_ratio: string;
};
@@ -23,12 +23,8 @@ export type VideoGenerationTaskRequest = {
*/
images?: Array<string>;
/**
* 分辨率(可选),如 720x1280
* 视频画幅比例,如 16:9 / 9:16
*/
size?: (string | null);
/**
* 画幅比例(可选),如 16:9 / 9:16
*/
ratio?: (string | null);
ratio: '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9';
};
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ApiResponse_ImageGenerationOptionsRead_ } from '../models/ApiResponse_ImageGenerationOptionsRead_';
import type { ApiResponse_list_ProviderSupportedRead__ } from '../models/ApiResponse_list_ProviderSupportedRead__';
import type { ApiResponse_ModelRead_ } from '../models/ApiResponse_ModelRead_';
import type { ApiResponse_ModelSettingsRead_ } from '../models/ApiResponse_ModelSettingsRead_';
@@ -9,6 +10,7 @@ import type { ApiResponse_NoneType_ } from '../models/ApiResponse_NoneType_';
import type { ApiResponse_PaginatedData_ModelRead__ } from '../models/ApiResponse_PaginatedData_ModelRead__';
import type { ApiResponse_PaginatedData_ProviderRead__ } from '../models/ApiResponse_PaginatedData_ProviderRead__';
import type { ApiResponse_ProviderRead_ } from '../models/ApiResponse_ProviderRead_';
import type { ApiResponse_VideoGenerationOptionsRead_ } from '../models/ApiResponse_VideoGenerationOptionsRead_';
import type { ModelCategoryKey } from '../models/ModelCategoryKey';
import type { ModelCreate } from '../models/ModelCreate';
import type { ModelSettingsUpdate } from '../models/ModelSettingsUpdate';
@@ -111,6 +113,28 @@ export class LlmService {
},
});
}
/**
* 获取当前默认图片模型的关键帧规格选项
* @returns ApiResponse_ImageGenerationOptionsRead_ Successful Response
* @throws ApiError
*/
public static getImageGenerationOptionsApiV1LlmImageGenerationOptionsGet(): CancelablePromise<ApiResponse_ImageGenerationOptionsRead_> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/llm/image-generation-options',
});
}
/**
* 获取当前默认视频模型的动态比例选项
* @returns ApiResponse_VideoGenerationOptionsRead_ Successful Response
* @throws ApiError
*/
public static getVideoGenerationOptionsApiV1LlmVideoGenerationOptionsGet(): CancelablePromise<ApiResponse_VideoGenerationOptionsRead_> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/llm/video-generation-options',
});
}
/**
* 获取单个模型供应商
* @returns ApiResponse_ProviderRead_ Successful Response
@@ -13,7 +13,7 @@ import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class StudioProjectsService {
/**
* 获取项目风格与视频参数候选项
* 获取项目风格候选项
* @returns ApiResponse_ProjectStyleOptionsRead_ Successful Response
* @throws ApiError
*/
@@ -70,12 +70,17 @@ backend/app/services/studio/generation/
当前 `preview-prompt` 与 `create video task` 已共享同一份 `reference_mode + images` 上下文。
当前视频参数(`size` / `ratio`)采用“项目默认 + 分镜覆盖 + 请求显式参数”解析:
当前视频参数已收口为以 `ratio` 为唯一业务主参数:
- 项目级默认:`Project.default_video_size` / `Project.default_video_ratio`
- 分镜级覆盖:`ShotDetail.override_video_size` / `ShotDetail.override_video_ratio`
- 执行优先级:请求参数 > 分镜覆盖 > 项目默认 > 供应商默认
- 冲突规则:若 `size` 推导比例与 `ratio` 冲突,任务创建阶段直接报错
- 项目级默认:`Project.default_video_ratio`
- 分镜级覆盖:`ShotDetail.override_video_ratio`
- 前端在提交视频任务时显式传入本次生效的 `ratio`
- 后端直接使用请求中的 `ratio` 创建任务
- 若某个供应商不直接支持 `ratio`,由 provider adapter 在执行层内部派生辅助 `size`
- 前端比例枚举来自当前默认视频模型 capability 动态返回,不再使用静态常量
- 关键帧图片若用于视频参考,提交时会显式携带 `target_ratio + resolution_profile`
- 后端根据当前默认图片模型 capability 解析对应 `size`,保证关键帧画幅与目标视频保持一致
- 工作室会展示当前关键帧规格预览:`ratio + resolution_profile -> size`
### `asset_image`