[feat] 新增图像和视频生成选项API,支持获取当前默认模型的关键帧规格和动态比例选项,更新相关逻辑以适应新参数
This commit is contained in:
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 自动确定;请求体不再接收覆盖值。
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""模型通用字段。"""
|
||||
|
||||
|
||||
@@ -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="视频尺寸候选项")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user