视频提词优化管理后台配置
This commit is contained in:
@@ -10,6 +10,15 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.common import (
|
||||
VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE,
|
||||
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
|
||||
VIDEO_SCHEMA_CONFIG_VERSION,
|
||||
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
|
||||
VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
|
||||
VIDEO_SCHEMA_TIME_DESC_MAX_LEN,
|
||||
VIDEO_SCHEMA_TIME_STAGE_MAX_LEN,
|
||||
)
|
||||
from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum
|
||||
from app.models.model_config import ModelConfig
|
||||
from app.models.token_usage import TokenUsage
|
||||
@@ -157,19 +166,362 @@ def _bounds_to_plan(bounds: list[int], stages: list[tuple[str, str]]) -> list[di
|
||||
return plan
|
||||
|
||||
|
||||
def build_time_plan(duration: int) -> list[dict[str, str]]:
|
||||
duration = max(1, int(duration))
|
||||
if duration <= 5:
|
||||
return _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.2, 0.4, 0.4]),
|
||||
[
|
||||
("开场吸引", "快速建立主体、产品和画面风格"),
|
||||
("核心展示", "展示主体动作、核心卖点或主要视觉内容"),
|
||||
("行动引导", "强化记忆点并给出转化引导"),
|
||||
def _default_time_plan_rules() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"min_duration": 1,
|
||||
"max_duration": 5,
|
||||
"ratios": [0.2, 0.4, 0.4],
|
||||
"segments": [
|
||||
{"stage": "开场吸引", "description": "快速建立主体、产品和画面风格"},
|
||||
{"stage": "核心展示", "description": "展示主体动作、核心卖点或主要视觉内容"},
|
||||
{"stage": "行动引导", "description": "强化记忆点并给出转化引导"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"min_duration": 6,
|
||||
"max_duration": 8,
|
||||
"ratios": [0.15, 0.3, 0.35, 0.2],
|
||||
"segments": [
|
||||
{"stage": "开场吸引", "description": "快速吸引注意力"},
|
||||
{"stage": "主体展示", "description": "展示主体和产品关系"},
|
||||
{"stage": "核心卖点", "description": "突出新项目核心内容点"},
|
||||
{"stage": "收尾引导", "description": "给出行动引导并稳定落版"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"min_duration": 9,
|
||||
"max_duration": 15,
|
||||
"ratios": [0.13, 0.2, 0.27, 0.25, 0.15],
|
||||
"segments": [
|
||||
{"stage": "爆款开头", "description": "复刻参考素材开头节奏和视觉吸引点"},
|
||||
{"stage": "主体建立", "description": "明确新项目主体和产品信息"},
|
||||
{"stage": "卖点放大", "description": "围绕核心内容点展开动作和镜头"},
|
||||
{"stage": "情绪推进", "description": "用动作、字幕或镜头变化强化记忆"},
|
||||
{"stage": "转化收尾", "description": "给出清晰行动引导"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _normalize_bool(value: Any, default: bool = True) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).strip().lower() not in {"0", "false", "no", "off", "否", "禁用"}
|
||||
|
||||
|
||||
def _normalize_int(value: Any, default: int, *, min_value: int = 1, max_value: int = 100000) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except Exception:
|
||||
number = default
|
||||
return max(min_value, min(max_value, number))
|
||||
|
||||
|
||||
def _truncate_for_config(value: Any, limit: int) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def _schema_value_type(value: Any) -> str:
|
||||
if isinstance(value, dict):
|
||||
return "object"
|
||||
if isinstance(value, list):
|
||||
return "array"
|
||||
if isinstance(value, bool):
|
||||
return "boolean"
|
||||
if isinstance(value, (int, float)):
|
||||
return "number"
|
||||
return "string"
|
||||
|
||||
|
||||
def _default_node(key: str, value: Any, *, editable: bool = True) -> dict[str, Any]:
|
||||
node: dict[str, Any] = {
|
||||
"key": str(key),
|
||||
"label": str(key),
|
||||
"type": _schema_value_type(value),
|
||||
"enabled": True,
|
||||
"editable": editable,
|
||||
"max_length": VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
|
||||
}
|
||||
if isinstance(value, dict):
|
||||
node["children"] = [_default_node(child_key, child_value, editable=editable) for child_key, child_value in value.items()]
|
||||
else:
|
||||
node["value"] = copy.deepcopy(value)
|
||||
return node
|
||||
|
||||
|
||||
def default_video_prompt_schema_config() -> dict[str, Any]:
|
||||
sections: list[dict[str, Any]] = []
|
||||
for key, value in CLIENT_SCHEMA_V1.items():
|
||||
if key in {"schema_version", "schema_usage"}:
|
||||
continue
|
||||
if key == "动作流程":
|
||||
sections.append(
|
||||
{
|
||||
"key": "动作流程",
|
||||
"label": "动作流程",
|
||||
"type": "flow",
|
||||
"enabled": True,
|
||||
"editable": True,
|
||||
"content_key": "动作内容",
|
||||
"content_aliases": list(ACTION_FLOW_CONTENT_KEYS),
|
||||
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
|
||||
"item_fields": [
|
||||
{"key": "动作内容", "label": "动作内容", "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}
|
||||
],
|
||||
}
|
||||
)
|
||||
elif key == "镜头流程":
|
||||
sections.append(
|
||||
{
|
||||
"key": "镜头流程",
|
||||
"label": "镜头流程",
|
||||
"type": "flow",
|
||||
"enabled": True,
|
||||
"editable": True,
|
||||
"content_key": "镜头内容",
|
||||
"content_aliases": list(CAMERA_FLOW_CONTENT_KEYS),
|
||||
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
|
||||
"item_fields": [
|
||||
{"key": "镜头内容", "label": "镜头内容", "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}
|
||||
],
|
||||
}
|
||||
)
|
||||
else:
|
||||
sections.append(_default_node(key, value, editable=True))
|
||||
return {
|
||||
"version": VIDEO_SCHEMA_CONFIG_VERSION,
|
||||
"enabled": True,
|
||||
"sections": sections,
|
||||
"time_plan_rules": _default_time_plan_rules(),
|
||||
}
|
||||
|
||||
|
||||
def _extract_config_data(schema_config_snapshot: Any | None) -> dict[str, Any] | None:
|
||||
if not isinstance(schema_config_snapshot, dict):
|
||||
return None
|
||||
data = schema_config_snapshot.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
if schema_config_snapshot.get("sections") or schema_config_snapshot.get("time_plan_rules"):
|
||||
return schema_config_snapshot
|
||||
return None
|
||||
|
||||
|
||||
def normalize_video_prompt_schema_config(config: Any | None) -> dict[str, Any]:
|
||||
source = config if isinstance(config, dict) else {}
|
||||
default_config = default_video_prompt_schema_config()
|
||||
if not source:
|
||||
return default_config
|
||||
|
||||
normalized: dict[str, Any] = {
|
||||
"version": str(source.get("version") or VIDEO_SCHEMA_CONFIG_VERSION),
|
||||
"enabled": _normalize_bool(source.get("enabled"), True),
|
||||
"sections": [],
|
||||
"time_plan_rules": [],
|
||||
}
|
||||
|
||||
raw_sections = source.get("sections") if isinstance(source.get("sections"), list) else default_config["sections"]
|
||||
for section in raw_sections:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
key = _truncate_for_config(section.get("key") or section.get("label"), 64)
|
||||
if not key:
|
||||
continue
|
||||
section_type = str(section.get("type") or "object")
|
||||
item: dict[str, Any] = {
|
||||
"key": key,
|
||||
"label": _truncate_for_config(section.get("label") or key, 64),
|
||||
"type": section_type,
|
||||
"enabled": _normalize_bool(section.get("enabled"), True),
|
||||
"editable": _normalize_bool(section.get("editable"), True),
|
||||
"max_length": _normalize_int(section.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
|
||||
}
|
||||
if section_type == "object":
|
||||
children = section.get("children") if isinstance(section.get("children"), list) else []
|
||||
item["children"] = []
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
child_key = _truncate_for_config(child.get("key") or child.get("label"), 64)
|
||||
if not child_key:
|
||||
continue
|
||||
item["children"].append(
|
||||
{
|
||||
"key": child_key,
|
||||
"label": _truncate_for_config(child.get("label") or child_key, 64),
|
||||
"type": str(child.get("type") or _schema_value_type(child.get("value"))),
|
||||
"enabled": _normalize_bool(child.get("enabled"), True),
|
||||
"editable": _normalize_bool(child.get("editable"), True),
|
||||
"max_length": _normalize_int(child.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
|
||||
"value": copy.deepcopy(child.get("value", "无")),
|
||||
}
|
||||
)
|
||||
elif section_type == "flow":
|
||||
content_key = _truncate_for_config(section.get("content_key") or section.get("contentKey") or ("镜头内容" if key == "镜头流程" else "动作内容"), 64)
|
||||
aliases = section.get("content_aliases") or section.get("contentAliases")
|
||||
if not isinstance(aliases, list) or not aliases:
|
||||
aliases = list(CAMERA_FLOW_CONTENT_KEYS if key == "镜头流程" else ACTION_FLOW_CONTENT_KEYS)
|
||||
item["content_key"] = content_key
|
||||
item["content_aliases"] = [_truncate_for_config(alias, 64) for alias in aliases if _truncate_for_config(alias, 64)]
|
||||
raw_fields = section.get("item_fields") or section.get("itemFields")
|
||||
if not isinstance(raw_fields, list) or not raw_fields:
|
||||
raw_fields = [{"key": content_key, "label": content_key, "enabled": True, "editable": True, "max_length": item["max_length"], "value": ""}]
|
||||
item["item_fields"] = []
|
||||
for field in raw_fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_key = _truncate_for_config(field.get("key") or field.get("label"), 64)
|
||||
if not field_key or field_key == "时间段":
|
||||
continue
|
||||
item["item_fields"].append(
|
||||
{
|
||||
"key": field_key,
|
||||
"label": _truncate_for_config(field.get("label") or field_key, 64),
|
||||
"enabled": _normalize_bool(field.get("enabled"), True),
|
||||
"editable": _normalize_bool(field.get("editable"), True),
|
||||
"max_length": _normalize_int(field.get("max_length"), item["max_length"], min_value=1, max_value=20000),
|
||||
"value": copy.deepcopy(field.get("value", "")),
|
||||
}
|
||||
)
|
||||
if not any(field["key"] == content_key for field in item["item_fields"]):
|
||||
item["item_fields"].insert(0, {"key": content_key, "label": content_key, "enabled": True, "editable": True, "max_length": item["max_length"], "value": ""})
|
||||
else:
|
||||
item["value"] = copy.deepcopy(section.get("value", [] if section_type == "array" else "无"))
|
||||
normalized["sections"].append(item)
|
||||
|
||||
raw_rules = source.get("time_plan_rules") or source.get("timePlanRules")
|
||||
if not isinstance(raw_rules, list) or not raw_rules:
|
||||
raw_rules = default_config["time_plan_rules"]
|
||||
for rule in raw_rules:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
|
||||
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
|
||||
normalized_segments: list[dict[str, str]] = []
|
||||
for segment in segments:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
normalized_segments.append(
|
||||
{
|
||||
"stage": _truncate_for_config(segment.get("stage") or "无", VIDEO_SCHEMA_TIME_STAGE_MAX_LEN),
|
||||
"description": _truncate_for_config(segment.get("description") or segment.get("desc") or "无", VIDEO_SCHEMA_TIME_DESC_MAX_LEN),
|
||||
}
|
||||
)
|
||||
if not normalized_segments:
|
||||
continue
|
||||
try:
|
||||
normalized_ratios = [float(item) for item in ratios]
|
||||
except Exception:
|
||||
normalized_ratios = []
|
||||
if len(normalized_ratios) != len(normalized_segments):
|
||||
normalized_ratios = [1 / len(normalized_segments)] * len(normalized_segments)
|
||||
ratio_total = sum(item for item in normalized_ratios if item > 0)
|
||||
if ratio_total <= 0:
|
||||
normalized_ratios = [1 / len(normalized_segments)] * len(normalized_segments)
|
||||
else:
|
||||
normalized_ratios = [max(0.01, item) / ratio_total for item in normalized_ratios]
|
||||
normalized["time_plan_rules"].append(
|
||||
{
|
||||
"min_duration": _normalize_int(rule.get("min_duration") or rule.get("minDuration"), 1, min_value=1, max_value=3600),
|
||||
"max_duration": _normalize_int(rule.get("max_duration") or rule.get("maxDuration"), 15, min_value=1, max_value=3600),
|
||||
"ratios": normalized_ratios,
|
||||
"segments": normalized_segments,
|
||||
}
|
||||
)
|
||||
if duration <= 8:
|
||||
return _bounds_to_plan(
|
||||
if not normalized["time_plan_rules"]:
|
||||
normalized["time_plan_rules"] = default_config["time_plan_rules"]
|
||||
return normalized
|
||||
|
||||
|
||||
def build_client_schema_from_config(schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
|
||||
if not _normalize_bool(data.get("enabled"), True):
|
||||
data = default_video_prompt_schema_config()
|
||||
schema: dict[str, Any] = {
|
||||
"schema_version": PromptSchemaVersionEnum.CLIENT_V1.value,
|
||||
"schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value,
|
||||
}
|
||||
for section in data.get("sections", []):
|
||||
if not isinstance(section, dict) or not _normalize_bool(section.get("enabled"), True):
|
||||
continue
|
||||
key = str(section.get("key") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
section_type = str(section.get("type") or "object")
|
||||
if section_type == "object":
|
||||
obj: dict[str, Any] = {}
|
||||
for child in section.get("children") or []:
|
||||
if not isinstance(child, dict) or not _normalize_bool(child.get("enabled"), True):
|
||||
continue
|
||||
child_key = str(child.get("key") or "").strip()
|
||||
if not child_key:
|
||||
continue
|
||||
obj[child_key] = copy.deepcopy(child.get("value", "无"))
|
||||
schema[key] = obj
|
||||
elif section_type == "flow":
|
||||
schema[key] = []
|
||||
else:
|
||||
schema[key] = copy.deepcopy(section.get("value", [] if section_type == "array" else "无"))
|
||||
return schema
|
||||
|
||||
|
||||
def build_schema_config_snapshot(schema_config: Any | None = None, *, source: str = VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) -> dict[str, Any]:
|
||||
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config) or schema_config)
|
||||
if not data:
|
||||
data = default_video_prompt_schema_config()
|
||||
return {"version": VIDEO_SCHEMA_CONFIG_VERSION, "source": source, "data": data}
|
||||
|
||||
|
||||
def _select_time_plan_rule(duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any] | None:
|
||||
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
|
||||
rules = data.get("time_plan_rules") if isinstance(data.get("time_plan_rules"), list) else []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
min_duration = _normalize_int(rule.get("min_duration"), 1, min_value=1, max_value=3600)
|
||||
max_duration = _normalize_int(rule.get("max_duration"), min_duration, min_value=1, max_value=3600)
|
||||
if min_duration <= duration <= max_duration:
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
def build_time_plan(duration: int, schema_config_snapshot: Any | None = None) -> list[dict[str, str]]:
|
||||
duration = max(1, int(duration))
|
||||
rule = _select_time_plan_rule(duration, schema_config_snapshot)
|
||||
if rule:
|
||||
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
|
||||
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
|
||||
stages: list[tuple[str, str]] = []
|
||||
for segment in segments:
|
||||
if isinstance(segment, dict):
|
||||
stages.append((str(segment.get("stage") or "无"), str(segment.get("description") or "无")))
|
||||
if stages:
|
||||
try:
|
||||
ratio_values = [float(item) for item in ratios]
|
||||
except Exception:
|
||||
ratio_values = []
|
||||
if len(ratio_values) != len(stages):
|
||||
ratio_values = [1 / len(stages)] * len(stages)
|
||||
ratio_total = sum(item for item in ratio_values if item > 0)
|
||||
if ratio_total <= 0:
|
||||
ratio_values = [1 / len(stages)] * len(stages)
|
||||
else:
|
||||
ratio_values = [max(0.01, item) / ratio_total for item in ratio_values]
|
||||
return _bounds_to_plan(_build_scaled_bounds(duration, ratio_values), stages)
|
||||
# fallback 保留旧规则,避免配置为空或异常时影响原流程。
|
||||
return _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.2, 0.4, 0.4]),
|
||||
[
|
||||
("开场吸引", "快速建立主体、产品和画面风格"),
|
||||
("核心展示", "展示主体动作、核心卖点或主要视觉内容"),
|
||||
("行动引导", "强化记忆点并给出转化引导"),
|
||||
],
|
||||
) if duration <= 5 else (
|
||||
_bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.15, 0.3, 0.35, 0.2]),
|
||||
[
|
||||
("开场吸引", "快速吸引注意力"),
|
||||
@@ -177,28 +529,32 @@ def build_time_plan(duration: int) -> list[dict[str, str]]:
|
||||
("核心卖点", "突出新项目核心内容点"),
|
||||
("收尾引导", "给出行动引导并稳定落版"),
|
||||
],
|
||||
) if duration <= 8 else _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.13, 0.2, 0.27, 0.25, 0.15]),
|
||||
[
|
||||
("爆款开头", "复刻参考素材开头节奏和视觉吸引点"),
|
||||
("主体建立", "明确新项目主体和产品信息"),
|
||||
("卖点放大", "围绕核心内容点展开动作和镜头"),
|
||||
("情绪推进", "用动作、字幕或镜头变化强化记忆"),
|
||||
("转化收尾", "给出清晰行动引导"),
|
||||
],
|
||||
)
|
||||
return _bounds_to_plan(
|
||||
_build_scaled_bounds(duration, [0.13, 0.2, 0.27, 0.25, 0.15]),
|
||||
[
|
||||
("爆款开头", "复刻参考素材开头节奏和视觉吸引点"),
|
||||
("主体建立", "明确新项目主体和产品信息"),
|
||||
("卖点放大", "围绕核心内容点展开动作和镜头"),
|
||||
("情绪推进", "用动作、字幕或镜头变化强化记忆"),
|
||||
("转化收尾", "给出清晰行动引导"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def build_dynamic_schema(video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = copy.deepcopy(CLIENT_SCHEMA_V1)
|
||||
def build_dynamic_schema(video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
schema = build_client_schema_from_config(schema_config_snapshot)
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
resolution = str(video_config["resolution"])
|
||||
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
|
||||
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
|
||||
|
||||
schema["画面属性"].update(
|
||||
frame = schema.setdefault("画面属性", {})
|
||||
if not isinstance(frame, dict):
|
||||
frame = {}
|
||||
schema["画面属性"] = frame
|
||||
frame.update(
|
||||
{
|
||||
"视频时长": f"{duration}秒",
|
||||
"视频比例": video_ratio,
|
||||
@@ -207,7 +563,24 @@ def build_dynamic_schema(video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"帧率": frame_rate,
|
||||
}
|
||||
)
|
||||
schema["动态时间规划"] = build_time_plan(duration)
|
||||
time_plan = build_time_plan(duration, schema_config_snapshot)
|
||||
schema["动态时间规划"] = time_plan
|
||||
|
||||
# 预览/AI 入参阶段也要把流程数组按秒数切片规则初始化出来。
|
||||
# 这样管理后台配置了 4 段/5 段规则后,动作流程、镜头流程会和动态时间规划保持相同长度,
|
||||
# AI 生成时也能明确知道每个时间段需要填哪个流程字段。
|
||||
for flow_key in ("动作流程", "镜头流程"):
|
||||
if flow_key not in schema:
|
||||
continue
|
||||
content_key = _flow_content_key(schema_config_snapshot, flow_key)
|
||||
schema[flow_key] = _align_flow_time_ranges(
|
||||
schema.get(flow_key),
|
||||
time_plan,
|
||||
content_key,
|
||||
content_keys=_flow_content_aliases(schema_config_snapshot, flow_key),
|
||||
item_fields=_flow_item_field_rules(schema_config_snapshot, flow_key),
|
||||
)
|
||||
|
||||
schema["输出规格限制"] = {
|
||||
"支持时长": _safe_list(video_config.get("supported_durations")),
|
||||
"支持比例": _safe_list(video_config.get("supported_ratios")),
|
||||
@@ -255,6 +628,7 @@ def build_user_text(
|
||||
references: list[dict[str, str]],
|
||||
video_config: dict[str, Any],
|
||||
client_schema: dict[str, Any],
|
||||
schema_config_snapshot: Any | None = None,
|
||||
) -> str:
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
@@ -286,7 +660,7 @@ def build_user_text(
|
||||
"参考素材": references,
|
||||
"输出要求": {
|
||||
"生成类型": infer_generation_type(references),
|
||||
"必须填充动态时间规划": build_time_plan(duration),
|
||||
"必须填充动态时间规划": build_time_plan(duration, schema_config_snapshot),
|
||||
"必须填充动作流程": "动作流程时间段必须覆盖完整视频时长",
|
||||
"必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长",
|
||||
"最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。",
|
||||
@@ -379,42 +753,119 @@ def _is_empty_schema_value(value: Any) -> bool:
|
||||
return _clean_schema_text(value) in EMPTY_VALUE_TEXTS
|
||||
|
||||
|
||||
def _normalize_schema_object(section_key: str, value: Any) -> dict[str, Any]:
|
||||
default_value = CLIENT_SCHEMA_V1.get(section_key)
|
||||
def _schema_default_for_normalize(schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
schema = build_client_schema_from_config(schema_config_snapshot)
|
||||
schema.setdefault("schema_version", PromptSchemaVersionEnum.CLIENT_V1.value)
|
||||
schema.setdefault("schema_usage", VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value)
|
||||
return schema
|
||||
|
||||
|
||||
def _object_field_whitelists(schema_config_snapshot: Any | None = None) -> dict[str, set[str]]:
|
||||
default_schema = _schema_default_for_normalize(schema_config_snapshot)
|
||||
whitelists: dict[str, set[str]] = {
|
||||
key: set(value.keys())
|
||||
for key, value in default_schema.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
whitelists.setdefault("画面属性", set()).update({"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"})
|
||||
whitelists["输出规格限制"] = {"支持时长", "支持比例", "支持分辨率", "当前推荐分辨率"}
|
||||
return whitelists
|
||||
|
||||
|
||||
def _normalize_schema_object(section_key: str, value: Any, schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
default_schema = _schema_default_for_normalize(schema_config_snapshot)
|
||||
default_value = default_schema.get(section_key)
|
||||
if not isinstance(default_value, dict):
|
||||
default_value = {}
|
||||
source = value if isinstance(value, dict) else {}
|
||||
merged = copy.deepcopy(default_value)
|
||||
allowed_keys = OBJECT_FIELD_WHITELISTS.get(section_key, set(default_value.keys()))
|
||||
allowed_keys = _object_field_whitelists(schema_config_snapshot).get(section_key, set(default_value.keys()))
|
||||
for field_key in allowed_keys:
|
||||
if field_key in source:
|
||||
merged[field_key] = fill_none_with_wu(source.get(field_key))
|
||||
return merged
|
||||
|
||||
|
||||
def normalize_top_level_schema_fields(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。
|
||||
|
||||
AI 偶尔会把解释性文本作为 JSON key 输出。普通对象中的非预设字段直接过滤,
|
||||
动作流程/镜头流程这类列表字段在后续 flow normalize 中单独处理。
|
||||
"""
|
||||
def normalize_top_level_schema_fields(result: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。"""
|
||||
source = result if isinstance(result, dict) else {}
|
||||
default_schema = _schema_default_for_normalize(schema_config_snapshot)
|
||||
whitelists = _object_field_whitelists(schema_config_snapshot)
|
||||
top_level_schema_keys = tuple(default_schema.keys()) + ("动态时间规划", "输出规格限制")
|
||||
normalized: dict[str, Any] = {}
|
||||
for key in TOP_LEVEL_SCHEMA_KEYS:
|
||||
if key in OBJECT_FIELD_WHITELISTS:
|
||||
normalized[key] = _normalize_schema_object(key, source.get(key))
|
||||
for key in top_level_schema_keys:
|
||||
if key in whitelists:
|
||||
normalized[key] = _normalize_schema_object(key, source.get(key), schema_config_snapshot)
|
||||
elif key in source:
|
||||
normalized[key] = fill_none_with_wu(source.get(key))
|
||||
elif key in CLIENT_SCHEMA_V1:
|
||||
normalized[key] = copy.deepcopy(CLIENT_SCHEMA_V1[key])
|
||||
for key, default_value in CLIENT_SCHEMA_V1.items():
|
||||
elif key in default_schema:
|
||||
normalized[key] = copy.deepcopy(default_schema[key])
|
||||
for key, default_value in default_schema.items():
|
||||
if key not in normalized:
|
||||
normalized[key] = copy.deepcopy(default_value)
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]:
|
||||
return normalize_top_level_schema_fields(result)
|
||||
def ensure_top_keys(result: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
return normalize_top_level_schema_fields(result, schema_config_snapshot)
|
||||
|
||||
|
||||
def _flow_section_config(schema_config_snapshot: Any | None, flow_key: str) -> dict[str, Any]:
|
||||
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
|
||||
for section in data.get("sections", []):
|
||||
if isinstance(section, dict) and section.get("key") == flow_key and section.get("type") == "flow" and _normalize_bool(section.get("enabled"), True):
|
||||
return section
|
||||
default_key = "镜头内容" if flow_key == "镜头流程" else "动作内容"
|
||||
aliases = CAMERA_FLOW_CONTENT_KEYS if flow_key == "镜头流程" else ACTION_FLOW_CONTENT_KEYS
|
||||
return {
|
||||
"key": flow_key,
|
||||
"type": "flow",
|
||||
"enabled": True,
|
||||
"editable": True,
|
||||
"content_key": default_key,
|
||||
"content_aliases": list(aliases),
|
||||
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
|
||||
"item_fields": [{"key": default_key, "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}],
|
||||
}
|
||||
|
||||
|
||||
def _flow_content_key(schema_config_snapshot: Any | None, flow_key: str) -> str:
|
||||
section = _flow_section_config(schema_config_snapshot, flow_key)
|
||||
return str(section.get("content_key") or ("镜头内容" if flow_key == "镜头流程" else "动作内容"))
|
||||
|
||||
|
||||
def _flow_content_aliases(schema_config_snapshot: Any | None, flow_key: str) -> tuple[str, ...]:
|
||||
section = _flow_section_config(schema_config_snapshot, flow_key)
|
||||
aliases = section.get("content_aliases") if isinstance(section.get("content_aliases"), list) else []
|
||||
values = [str(item) for item in aliases if str(item).strip()]
|
||||
content_key = _flow_content_key(schema_config_snapshot, flow_key)
|
||||
if content_key not in values:
|
||||
values.insert(0, content_key)
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _flow_item_field_rules(schema_config_snapshot: Any | None, flow_key: str) -> list[dict[str, Any]]:
|
||||
section = _flow_section_config(schema_config_snapshot, flow_key)
|
||||
fields = section.get("item_fields") if isinstance(section.get("item_fields"), list) else []
|
||||
result: list[dict[str, Any]] = []
|
||||
for field in fields:
|
||||
if not isinstance(field, dict) or not _normalize_bool(field.get("enabled"), True):
|
||||
continue
|
||||
key = str(field.get("key") or "").strip()
|
||||
if not key or key == "时间段":
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"key": key,
|
||||
"editable": _normalize_bool(field.get("editable"), True),
|
||||
"max_length": _normalize_int(field.get("max_length"), VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, min_value=1, max_value=20000),
|
||||
"value": field.get("value", ""),
|
||||
}
|
||||
)
|
||||
content_key = _flow_content_key(schema_config_snapshot, flow_key)
|
||||
if not any(item["key"] == content_key for item in result):
|
||||
result.insert(0, {"key": content_key, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""})
|
||||
return result
|
||||
|
||||
|
||||
def _pick_flow_content(item: dict[str, Any], content_keys: tuple[str, ...]) -> str:
|
||||
@@ -465,16 +916,24 @@ def _normalize_flow_item(
|
||||
plan_item: dict[str, str],
|
||||
content_key: str,
|
||||
content_keys: tuple[str, ...],
|
||||
item_fields: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, str]:
|
||||
item = raw_item if isinstance(raw_item, dict) else {}
|
||||
allowed_keys = {"时间段", content_key}
|
||||
field_rules = item_fields or [{"key": content_key, "value": ""}]
|
||||
allowed_keys = {"时间段", *(str(field.get("key")) for field in field_rules if field.get("key"))}
|
||||
base_content = _pick_flow_content(item, content_keys)
|
||||
extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys)
|
||||
fallback = plan_item.get("说明") or "无"
|
||||
return {
|
||||
"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or "无",
|
||||
content_key: _merge_flow_content(base_content, extra_texts, fallback),
|
||||
}
|
||||
result: dict[str, str] = {"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or "无"}
|
||||
for field in field_rules:
|
||||
field_key = str(field.get("key") or "").strip()
|
||||
if not field_key:
|
||||
continue
|
||||
if field_key == content_key:
|
||||
result[field_key] = _merge_flow_content(base_content, extra_texts, fallback)
|
||||
else:
|
||||
result[field_key] = _clean_schema_text(item.get(field_key)) or _clean_schema_text(field.get("value")) or "无"
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[str, str]]:
|
||||
@@ -492,10 +951,24 @@ def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[st
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]:
|
||||
plan = build_time_plan(duration)
|
||||
result["动作流程"] = _align_flow_time_ranges(result.get("动作流程"), plan, "动作内容")
|
||||
result["镜头流程"] = _align_flow_time_ranges(result.get("镜头流程"), plan, "镜头内容")
|
||||
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
plan = build_time_plan(duration, schema_config_snapshot)
|
||||
action_key = _flow_content_key(schema_config_snapshot, "动作流程")
|
||||
camera_key = _flow_content_key(schema_config_snapshot, "镜头流程")
|
||||
result["动作流程"] = _align_flow_time_ranges(
|
||||
result.get("动作流程"),
|
||||
plan,
|
||||
action_key,
|
||||
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
|
||||
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
|
||||
)
|
||||
result["镜头流程"] = _align_flow_time_ranges(
|
||||
result.get("镜头流程"),
|
||||
plan,
|
||||
camera_key,
|
||||
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
|
||||
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
|
||||
)
|
||||
result["动态时间规划"] = _normalize_time_plan(plan, result.get("动态时间规划"))
|
||||
return result
|
||||
|
||||
@@ -591,9 +1064,16 @@ def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any
|
||||
return schema
|
||||
|
||||
|
||||
def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, str]]:
|
||||
def _align_flow_time_ranges(
|
||||
flow: Any,
|
||||
plan: list[dict[str, str]],
|
||||
default_content_key: str,
|
||||
*,
|
||||
content_keys: tuple[str, ...] | None = None,
|
||||
item_fields: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
source = flow if isinstance(flow, list) else []
|
||||
content_keys = ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS
|
||||
aliases = content_keys or (ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS)
|
||||
aligned: list[dict[str, str]] = []
|
||||
for index, plan_item in enumerate(plan):
|
||||
raw_item = source[index] if index < len(source) else {}
|
||||
@@ -602,43 +1082,110 @@ def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_conte
|
||||
raw_item,
|
||||
plan_item=plan_item,
|
||||
content_key=default_content_key,
|
||||
content_keys=content_keys,
|
||||
content_keys=aliases,
|
||||
item_fields=item_fields,
|
||||
)
|
||||
)
|
||||
return aligned
|
||||
|
||||
|
||||
def _merge_editable_dict_fields(base: dict[str, Any], patch: dict[str, Any], allowed_keys: set[str]) -> None:
|
||||
def _config_editable_field_rules(schema_config_snapshot: Any | None = None) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
|
||||
rules: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for section in data.get("sections", []):
|
||||
if not isinstance(section, dict) or not _normalize_bool(section.get("enabled"), True):
|
||||
continue
|
||||
if str(section.get("type") or "object") != "object":
|
||||
continue
|
||||
section_key = str(section.get("key") or "").strip()
|
||||
for child in section.get("children") or []:
|
||||
if not isinstance(child, dict) or not _normalize_bool(child.get("enabled"), True):
|
||||
continue
|
||||
field_key = str(child.get("key") or "").strip()
|
||||
if not section_key or not field_key:
|
||||
continue
|
||||
rules[(section_key, field_key)] = {
|
||||
"editable": _normalize_bool(child.get("editable"), True),
|
||||
"max_length": _normalize_int(child.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
|
||||
}
|
||||
return rules
|
||||
|
||||
|
||||
def _validate_editable_value_length(path: str, value: Any, max_length: int) -> None:
|
||||
if isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
_validate_editable_value_length(f"{path}[{index}]", item, max_length)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
_validate_editable_value_length(f"{path}.{key}", item, max_length)
|
||||
return
|
||||
text = str(value or "")
|
||||
if len(text) > max_length:
|
||||
raise ValueError(f"字段【{path}】长度不能超过 {max_length} 个字符")
|
||||
|
||||
|
||||
def _is_editable_config_field(section_key: str, field_key: str, schema_config_snapshot: Any | None = None) -> tuple[bool, int]:
|
||||
# 视频规格、动态规划、输出限制、合规和质量控制继续按原逻辑锁定。
|
||||
if section_key in {"合规控制", "质量控制", "输出规格限制"}:
|
||||
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
|
||||
if section_key == "画面属性" and field_key in {"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"}:
|
||||
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
|
||||
rules = _config_editable_field_rules(schema_config_snapshot)
|
||||
rule = rules.get((section_key, field_key))
|
||||
if rule is None:
|
||||
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
|
||||
return bool(rule.get("editable", True)), int(rule.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN)
|
||||
|
||||
|
||||
def _merge_editable_dict_fields(
|
||||
section_key: str,
|
||||
base: dict[str, Any],
|
||||
patch: dict[str, Any],
|
||||
allowed_keys: set[str],
|
||||
schema_config_snapshot: Any | None = None,
|
||||
) -> None:
|
||||
for key in allowed_keys:
|
||||
if key in patch:
|
||||
base[key] = fill_none_with_wu(patch.get(key))
|
||||
if key not in patch:
|
||||
continue
|
||||
editable, max_length = _is_editable_config_field(section_key, key, schema_config_snapshot)
|
||||
if not editable:
|
||||
continue
|
||||
_validate_editable_value_length(f"{section_key}.{key}", patch.get(key), max_length)
|
||||
base[key] = fill_none_with_wu(patch.get(key))
|
||||
|
||||
|
||||
def _merge_flow_patch(base_flow: Any, patch_flow: Any) -> list[dict[str, Any]]:
|
||||
def _merge_flow_patch(base_flow: Any, patch_flow: Any, flow_key: str, schema_config_snapshot: Any | None = None) -> list[dict[str, Any]]:
|
||||
base = [dict(item) for item in base_flow] if isinstance(base_flow, list) else []
|
||||
patch = patch_flow if isinstance(patch_flow, list) else []
|
||||
result: list[dict[str, Any]] = []
|
||||
field_rules = {item["key"]: item for item in _flow_item_field_rules(schema_config_snapshot, flow_key)}
|
||||
for index, base_item in enumerate(base):
|
||||
merged = dict(base_item)
|
||||
patch_item = patch[index] if index < len(patch) and isinstance(patch[index], dict) else {}
|
||||
original_time_range = merged.get("时间段")
|
||||
for key, value in patch_item.items():
|
||||
if key == "时间段":
|
||||
if key == "时间段" or key not in field_rules:
|
||||
continue
|
||||
rule = field_rules[key]
|
||||
if not _normalize_bool(rule.get("editable"), True):
|
||||
continue
|
||||
max_length = _normalize_int(rule.get("max_length"), VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, min_value=1, max_value=20000)
|
||||
_validate_editable_value_length(f"{flow_key}[{index}].{key}", value, max_length)
|
||||
merged[key] = fill_none_with_wu(value)
|
||||
merged["时间段"] = original_time_range
|
||||
result.append(merged)
|
||||
return result
|
||||
|
||||
|
||||
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
duration = int(video_config["duration"])
|
||||
video_ratio = str(video_config["aspect_ratio"])
|
||||
resolution = str(video_config["resolution"])
|
||||
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
|
||||
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
|
||||
dynamic_schema = build_dynamic_schema(video_config)
|
||||
plan = build_time_plan(duration)
|
||||
dynamic_schema = build_dynamic_schema(video_config, schema_config_snapshot)
|
||||
plan = build_time_plan(duration, schema_config_snapshot)
|
||||
|
||||
schema["schema_version"] = PromptSchemaVersionEnum.CLIENT_V1.value
|
||||
schema["schema_usage"] = VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value
|
||||
@@ -660,25 +1207,37 @@ def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[
|
||||
schema["动态时间规划"] = plan
|
||||
schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {})
|
||||
|
||||
schema["动作流程"] = _align_flow_time_ranges(schema.get("动作流程"), plan, "动作内容")
|
||||
schema["镜头流程"] = _align_flow_time_ranges(schema.get("镜头流程"), plan, "镜头内容")
|
||||
schema["动作流程"] = _align_flow_time_ranges(
|
||||
schema.get("动作流程"),
|
||||
plan,
|
||||
_flow_content_key(schema_config_snapshot, "动作流程"),
|
||||
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
|
||||
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
|
||||
)
|
||||
schema["镜头流程"] = _align_flow_time_ranges(
|
||||
schema.get("镜头流程"),
|
||||
plan,
|
||||
_flow_content_key(schema_config_snapshot, "镜头流程"),
|
||||
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
|
||||
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
|
||||
)
|
||||
|
||||
# 合规和质量控制不能被前端降低;AI 返回缺失时使用服务端默认结构补齐。
|
||||
default_schema = copy.deepcopy(CLIENT_SCHEMA_V1)
|
||||
if not isinstance(schema.get("合规控制"), dict):
|
||||
schema["合规控制"] = default_schema["合规控制"]
|
||||
if not isinstance(schema.get("质量控制"), dict):
|
||||
schema["质量控制"] = default_schema["质量控制"]
|
||||
default_schema = build_client_schema_from_config(schema_config_snapshot)
|
||||
if not isinstance(schema.get("合规控制"), dict) and isinstance(default_schema.get("合规控制"), dict):
|
||||
schema["合规控制"] = copy.deepcopy(default_schema["合规控制"])
|
||||
if not isinstance(schema.get("质量控制"), dict) and isinstance(default_schema.get("质量控制"), dict):
|
||||
schema["质量控制"] = copy.deepcopy(default_schema["质量控制"])
|
||||
|
||||
return clean_final_prompt_specs(schema, video_config)
|
||||
|
||||
|
||||
def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]:
|
||||
def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
|
||||
duration = int(video_config["duration"])
|
||||
normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {}))
|
||||
normalized = ensure_flow_matches_time_plan(normalized, duration)
|
||||
normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {}), schema_config_snapshot)
|
||||
normalized = ensure_flow_matches_time_plan(normalized, duration, schema_config_snapshot)
|
||||
normalized = ensure_negative_prompt(normalized)
|
||||
return apply_locked_video_schema_fields(normalized, video_config)
|
||||
return apply_locked_video_schema_fields(normalized, video_config, schema_config_snapshot)
|
||||
|
||||
|
||||
def patch_video_prompt_schema_from_client(
|
||||
@@ -686,38 +1245,50 @@ def patch_video_prompt_schema_from_client(
|
||||
server_schema: dict[str, Any],
|
||||
client_schema: dict[str, Any],
|
||||
video_config: dict[str, Any],
|
||||
schema_config_snapshot: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""以前端 JSON 作为 patch,回填到服务端已有 schema。
|
||||
|
||||
禁止整包覆盖:数组长度、时间段、视频规格、输出规格、质量控制、合规控制、schema 协议字段均以服务端为准。
|
||||
仅对 schema_config_snapshot 中 enabled=true 且 editable=true 的用户可编辑字段做长度校验,不自动裁剪。
|
||||
"""
|
||||
base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {})))
|
||||
base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {})), schema_config_snapshot)
|
||||
patch = client_schema if isinstance(client_schema, dict) else {}
|
||||
whitelists = _object_field_whitelists(schema_config_snapshot)
|
||||
|
||||
for key in ("基础分类", "素材理解", "业务属性", "字幕与口播", "音频与节奏"):
|
||||
if isinstance(base.get(key), dict) and isinstance(patch.get(key), dict):
|
||||
base[key].update(fill_none_with_wu(patch[key]))
|
||||
for section_key, allowed_keys in whitelists.items():
|
||||
if section_key in {"画面属性", "输出规格限制", "合规控制", "质量控制"}:
|
||||
continue
|
||||
if isinstance(base.get(section_key), dict) and isinstance(patch.get(section_key), dict):
|
||||
_merge_editable_dict_fields(section_key, base[section_key], patch[section_key], allowed_keys, schema_config_snapshot)
|
||||
|
||||
if isinstance(base.get("画面属性"), dict) and isinstance(patch.get("画面属性"), dict):
|
||||
_merge_editable_dict_fields(
|
||||
"画面属性",
|
||||
base["画面属性"],
|
||||
patch["画面属性"],
|
||||
{"主体描述", "主体数量", "主体位置", "主体占比", "场景描述", "构图方式", "画面风格", "光影色彩"},
|
||||
schema_config_snapshot,
|
||||
)
|
||||
|
||||
if isinstance(patch.get("动作流程"), list):
|
||||
base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程"))
|
||||
base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程"), "动作流程", schema_config_snapshot)
|
||||
if isinstance(patch.get("镜头流程"), list):
|
||||
base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程"))
|
||||
base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程"), "镜头流程", schema_config_snapshot)
|
||||
|
||||
# 动态时间规划保持服务端数组长度和时间段,只允许保留原值;不接受客户端 patch。
|
||||
|
||||
if isinstance(base.get("最终提示词"), dict) and isinstance(patch.get("最终提示词"), dict):
|
||||
for key in VIDEO_SPEC_PROMPT_KEYS:
|
||||
if key in patch["最终提示词"]:
|
||||
base["最终提示词"][key] = fill_none_with_wu(patch["最终提示词"].get(key))
|
||||
if key not in patch["最终提示词"]:
|
||||
continue
|
||||
editable, max_length = _is_editable_config_field("最终提示词", key, schema_config_snapshot)
|
||||
if not editable:
|
||||
continue
|
||||
_validate_editable_value_length(f"最终提示词.{key}", patch["最终提示词"].get(key), max_length)
|
||||
base["最终提示词"][key] = fill_none_with_wu(patch["最终提示词"].get(key))
|
||||
|
||||
return apply_locked_video_schema_fields(base, video_config)
|
||||
return apply_locked_video_schema_fields(base, video_config, schema_config_snapshot)
|
||||
|
||||
|
||||
def _mock_result(video_config: dict[str, Any], target_platform: str) -> dict[str, Any]:
|
||||
@@ -758,13 +1329,14 @@ async def optimize_hot_opening_video_prompt(
|
||||
generated_image_url: str,
|
||||
video_config: dict[str, Any],
|
||||
target_platform: str = "抖音",
|
||||
schema_config_snapshot: Any | None = None,
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
duration = int(video_config["duration"])
|
||||
references = [
|
||||
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)},
|
||||
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
|
||||
]
|
||||
client_schema = build_dynamic_schema(video_config)
|
||||
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
|
||||
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
|
||||
|
||||
# if settings.LLM_MOCK:
|
||||
@@ -774,7 +1346,7 @@ async def optimize_hot_opening_video_prompt(
|
||||
|
||||
config = await _select_model_config(db)
|
||||
if not config:
|
||||
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config)
|
||||
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
|
||||
return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
user_text = build_user_text(
|
||||
@@ -785,6 +1357,7 @@ async def optimize_hot_opening_video_prompt(
|
||||
references=references,
|
||||
video_config=video_config,
|
||||
client_schema=client_schema,
|
||||
schema_config_snapshot=schema_config_snapshot,
|
||||
)
|
||||
user_message, log_user_message = build_user_message(user_text, references, reference_video_fps)
|
||||
request_data = {
|
||||
@@ -826,7 +1399,7 @@ async def optimize_hot_opening_video_prompt(
|
||||
await db.flush()
|
||||
|
||||
result = parse_model_json(content)
|
||||
result = normalize_video_prompt_schema_from_ai(result, video_config)
|
||||
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
|
||||
return result, build_final_video_prompt(result), token_usage
|
||||
|
||||
def _build_file_url_or_data_uri(file_url: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user