视频提词优化管理后台配置BUG修复

This commit is contained in:
2026-06-22 15:56:09 +08:00
parent ada0243919
commit 03e5b049b4
5 changed files with 597 additions and 467 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CYRc4RUo.js"></script> <script type="module" crossorigin src="/assets/index-Te6nFn2L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
@@ -58,6 +58,17 @@ const NODE_TYPES: { label: string; value: VideoPromptSchemaNodeType }[] = [
{ label: '流程数组', value: 'flow' }, { label: '流程数组', value: 'flow' },
]; ];
const VIDEO_SPEC_SECTION_KEY = '画面属性';
const VIDEO_SPEC_LOCKED_FIELDS = new Set(['视频时长', '视频比例', '清晰度', '帧率', '推荐分辨率']);
function isLockedVideoSpecField(sectionKey: string | undefined, fieldKey: string | undefined): boolean {
return sectionKey === VIDEO_SPEC_SECTION_KEY && !!fieldKey && VIDEO_SPEC_LOCKED_FIELDS.has(fieldKey);
}
function isLockedVideoSpecSection(sectionKey: string | undefined): boolean {
return sectionKey === VIDEO_SPEC_SECTION_KEY;
}
function clone<T>(value: T): T { function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null)); return JSON.parse(JSON.stringify(value ?? null));
} }
@@ -137,17 +148,19 @@ function normalizeConfig(data: any): VideoPromptSchemaConfigData {
}; };
} }
function toApiNode(node: VideoPromptSchemaNode): any { function toApiNode(node: VideoPromptSchemaNode, parentKey?: string): any {
const lockedSection = isLockedVideoSpecSection(node.key);
const lockedField = isLockedVideoSpecField(parentKey, node.key);
const base: any = { const base: any = {
key: node.key, key: node.key,
label: node.label, label: node.label,
type: node.type, type: lockedField ? 'string' : node.type,
enabled: !!node.enabled, enabled: lockedSection || lockedField ? true : !!node.enabled,
editable: !!node.editable, editable: lockedField ? false : !!node.editable,
max_length: node.maxLength, max_length: node.maxLength,
}; };
if (node.type === 'object') { if (node.type === 'object') {
base.children = (node.children || []).map(toApiNode); base.children = (node.children || []).map(child => toApiNode(child, node.key));
} else if (node.type === 'flow') { } else if (node.type === 'flow') {
base.content_key = node.contentKey; base.content_key = node.contentKey;
base.content_aliases = node.contentAliases || []; base.content_aliases = node.contentAliases || [];
@@ -169,7 +182,7 @@ function toApiConfig(config: VideoPromptSchemaConfigData, enabled: boolean): any
return { return {
version: config.version, version: config.version,
enabled, enabled,
sections: (config.sections || []).map(toApiNode), sections: (config.sections || []).map(section => toApiNode(section)),
time_plan_rules: (config.timePlanRules || []).map(rule => ({ time_plan_rules: (config.timePlanRules || []).map(rule => ({
min_duration: rule.minDuration, min_duration: rule.minDuration,
max_duration: rule.maxDuration, max_duration: rule.maxDuration,
@@ -364,6 +377,12 @@ const AdminVideoPromptSchemaConfig: React.FC = () => {
const updateSection = (index: number, patch: Partial<VideoPromptSchemaNode>) => { const updateSection = (index: number, patch: Partial<VideoPromptSchemaNode>) => {
updateConfig(draft => { updateConfig(draft => {
const current = draft.sections[index];
if (isLockedVideoSpecSection(current?.key)) {
delete patch.key;
delete patch.type;
delete patch.enabled;
}
draft.sections[index] = { ...draft.sections[index], ...patch }; draft.sections[index] = { ...draft.sections[index], ...patch };
if (patch.type === 'object') { if (patch.type === 'object') {
draft.sections[index].children = draft.sections[index].children || []; draft.sections[index].children = draft.sections[index].children || [];
@@ -392,7 +411,15 @@ const AdminVideoPromptSchemaConfig: React.FC = () => {
const updateChild = (sectionIndex: number, childIndex: number, patch: Partial<VideoPromptSchemaNode>) => { const updateChild = (sectionIndex: number, childIndex: number, patch: Partial<VideoPromptSchemaNode>) => {
updateConfig(draft => { updateConfig(draft => {
const children = draft.sections[sectionIndex].children || []; const section = draft.sections[sectionIndex];
const children = section.children || [];
const child = children[childIndex];
if (isLockedVideoSpecField(section?.key, child?.key)) {
delete patch.key;
delete patch.type;
delete patch.enabled;
patch.editable = false;
}
children[childIndex] = { ...children[childIndex], ...patch }; children[childIndex] = { ...children[childIndex], ...patch };
draft.sections[sectionIndex].children = children; draft.sections[sectionIndex].children = children;
}); });
@@ -470,29 +497,30 @@ const AdminVideoPromptSchemaConfig: React.FC = () => {
}; };
const sectionItems = useMemo(() => sections.map((section, sectionIndex) => ({ const sectionItems = useMemo(() => sections.map((section, sectionIndex) => ({
key: `${section.key}-${sectionIndex}`, key: `section-${sectionIndex}`,
label: ( label: (
<Space> <Space>
<Text strong>{section.label || section.key || '未命名分组'}</Text> <Text strong>{section.label || section.key || '未命名分组'}</Text>
{section.key && section.key !== section.label && <Tag color="blue">key: {section.key}</Tag>} {section.key && section.key !== section.label && <Tag color="blue">key: {section.key}</Tag>}
<Tag>{section.type}</Tag> <Tag>{section.type}</Tag>
{!section.enabled && <Tag color="default"></Tag>} {isLockedVideoSpecSection(section.key) && <Tag color="red"></Tag>}
{!section.enabled && !isLockedVideoSpecSection(section.key) && <Tag color="default"></Tag>}
</Space> </Space>
), ),
children: ( children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={12} align="middle"> <Row gutter={12} align="middle">
<Col span={4}><Input addonBefore="key" value={section.key} maxLength={64} placeholder="传给 AI 的字段名" onChange={e => updateSection(sectionIndex, { key: e.target.value })} /></Col> <Col span={4}><Input addonBefore="key" value={section.key} maxLength={64} placeholder="传给 AI 的字段名" disabled={isLockedVideoSpecSection(section.key)} onChange={e => updateSection(sectionIndex, { key: e.target.value })} /></Col>
<Col span={4}><Input addonBefore="label" value={section.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateSection(sectionIndex, { label: e.target.value })} /></Col> <Col span={4}><Input addonBefore="label" value={section.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateSection(sectionIndex, { label: e.target.value })} /></Col>
<Col span={4}> <Col span={4}>
<Select value={section.type as any} options={NODE_TYPES} style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { type: value })} /> <Select value={section.type as any} options={NODE_TYPES} style={{ width: '100%' }} disabled={isLockedVideoSpecSection(section.key)} onChange={value => updateSection(sectionIndex, { type: value })} />
</Col> </Col>
<Col span={3}><Space><Switch checked={section.enabled} onChange={checked => updateSection(sectionIndex, { enabled: checked })} /></Space></Col> <Col span={3}><Space><Switch checked={isLockedVideoSpecSection(section.key) ? true : section.enabled} disabled={isLockedVideoSpecSection(section.key)} onChange={checked => updateSection(sectionIndex, { enabled: checked })} /></Space></Col>
<Col span={3}><Space><Switch checked={section.editable} onChange={checked => updateSection(sectionIndex, { editable: checked })} /></Space></Col> <Col span={3}><Space><Switch checked={section.editable} onChange={checked => updateSection(sectionIndex, { editable: checked })} /></Space></Col>
<Col span={4}><InputNumber min={1} max={20000} value={section.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { maxLength: Number(value || 1000) })} /></Col> <Col span={4}><InputNumber min={1} max={20000} value={section.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { maxLength: Number(value || 1000) })} /></Col>
<Col span={2} style={{ textAlign: 'right' }}> <Col span={2} style={{ textAlign: 'right' }}>
<Popconfirm title="确认删除该分组?" onConfirm={() => deleteSection(sectionIndex)}> <Popconfirm title="确认删除该分组?" disabled={isLockedVideoSpecSection(section.key)} onConfirm={() => deleteSection(sectionIndex)}>
<Button danger icon={<DeleteOutlined />} /> <Button danger icon={<DeleteOutlined />} disabled={isLockedVideoSpecSection(section.key)} />
</Popconfirm> </Popconfirm>
</Col> </Col>
</Row> </Row>
@@ -501,18 +529,18 @@ const AdminVideoPromptSchemaConfig: React.FC = () => {
<Card size="small" title="对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addChild(sectionIndex)}></Button>}> <Card size="small" title="对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addChild(sectionIndex)}></Button>}>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{(section.children || []).map((child, childIndex) => ( {(section.children || []).map((child, childIndex) => (
<Row gutter={8} align="middle" key={`${child.key}-${childIndex}`}> <Row gutter={8} align="middle" key={`child-${sectionIndex}-${childIndex}`}>
<Col span={4}><Input addonBefore="key" value={child.key} maxLength={64} placeholder="传给 AI 的字段名" onChange={e => updateChild(sectionIndex, childIndex, { key: e.target.value })} /></Col> <Col span={4}><Input addonBefore="key" value={child.key} maxLength={64} placeholder="传给 AI 的字段名" disabled={isLockedVideoSpecField(section.key, child.key)} onChange={e => updateChild(sectionIndex, childIndex, { key: e.target.value })} /></Col>
<Col span={4}><Input addonBefore="label" value={child.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateChild(sectionIndex, childIndex, { label: e.target.value })} /></Col> <Col span={4}><Input addonBefore="label" value={child.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateChild(sectionIndex, childIndex, { label: e.target.value })} /></Col>
<Col span={3}><Select value={child.type as any} options={NODE_TYPES.filter(item => item.value !== 'object' && item.value !== 'flow')} style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { type: value })} /></Col> <Col span={3}><Select value={child.type as any} options={NODE_TYPES.filter(item => item.value !== 'object' && item.value !== 'flow')} style={{ width: '100%' }} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={value => updateChild(sectionIndex, childIndex, { type: value })} /></Col>
<Col span={2}><Space><Switch checked={child.enabled} onChange={checked => updateChild(sectionIndex, childIndex, { enabled: checked })} /></Space></Col> <Col span={2}><Space><Switch checked={isLockedVideoSpecField(section.key, child.key) ? true : child.enabled} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={checked => updateChild(sectionIndex, childIndex, { enabled: checked })} /></Space></Col>
<Col span={2}><Space><Switch checked={child.editable} onChange={checked => updateChild(sectionIndex, childIndex, { editable: checked })} /></Space></Col> <Col span={2}><Space><Switch checked={isLockedVideoSpecField(section.key, child.key) ? false : child.editable} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={checked => updateChild(sectionIndex, childIndex, { editable: checked })} /></Space></Col>
<Col span={3}><InputNumber min={1} max={20000} value={child.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { maxLength: Number(value || 1000) })} /></Col> <Col span={3}><InputNumber min={1} max={20000} value={child.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { maxLength: Number(value || 1000) })} /></Col>
<Col span={5}> <Col span={5}>
<Input value={Array.isArray(child.value) ? child.value.join('/') : String(child.value ?? '')} maxLength={child.maxLength || 1000} placeholder="默认值" onChange={e => updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} /> <Input value={Array.isArray(child.value) ? child.value.join('/') : String(child.value ?? '')} maxLength={child.maxLength || 1000} placeholder="默认值" onChange={e => updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} />
</Col> </Col>
<Col span={1} style={{ textAlign: 'right' }}> <Col span={1} style={{ textAlign: 'right' }}>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteChild(sectionIndex, childIndex)} /> <Button danger size="small" icon={<DeleteOutlined />} disabled={isLockedVideoSpecField(section.key, child.key)} onClick={() => deleteChild(sectionIndex, childIndex)} />
</Col> </Col>
</Row> </Row>
))} ))}
@@ -528,7 +556,7 @@ const AdminVideoPromptSchemaConfig: React.FC = () => {
</Row> </Row>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{(section.itemFields || []).map((field, fieldIndex) => ( {(section.itemFields || []).map((field, fieldIndex) => (
<Row gutter={8} align="middle" key={`${field.key}-${fieldIndex}`}> <Row gutter={8} align="middle" key={`flow-${sectionIndex}-${fieldIndex}`}>
<Col span={5}><Input addonBefore="key" value={field.key} maxLength={64} placeholder="流程对象字段名" onChange={e => updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /></Col> <Col span={5}><Input addonBefore="key" value={field.key} maxLength={64} placeholder="流程对象字段名" onChange={e => updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /></Col>
<Col span={5}><Input addonBefore="label" value={field.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /></Col> <Col span={5}><Input addonBefore="label" value={field.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /></Col>
<Col span={3}><Space><Switch checked={field.enabled} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /></Space></Col> <Col span={3}><Space><Switch checked={field.enabled} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /></Space></Col>
@@ -612,7 +640,7 @@ const AdminVideoPromptSchemaConfig: React.FC = () => {
type="warning" type="warning"
showIcon showIcon
message="字段说明" message="字段说明"
description="key 是最终传给 AI 和保存到 prompt_schema 的真实字段名,例如「素材理解」「动作流程」;label 只用于管理后台显示,帮助运营/管理员理解这个字段,不会传给 AI;启用关闭后该字段不会进入运行时 schema;可编辑关闭后用户在第 4 步修改时不能改这个字段。" description="key 是最终传给 AI 和保存到 prompt_schema 的真实字段名,例如「素材理解」「动作流程」;label 只用于管理后台显示,帮助运营/管理员理解这个字段,不会传给 AI;启用关闭后该字段不会进入运行时 schema;可编辑关闭后用户在第 4 步修改时不能改这个字段。「画面属性」里的视频时长、视频比例、清晰度、帧率、推荐分辨率属于系统锁定参数,不能改 key、不能禁用、不能开放用户编辑。"
/> />
<Button icon={<PlusOutlined />} onClick={addSection}></Button> <Button icon={<PlusOutlined />} onClick={addSection}></Button>
<Collapse items={sectionItems} /> <Collapse items={sectionItems} />
@@ -28,6 +28,13 @@ from app.services.resource_signed_url_service import build_resource_signed_url
DEFAULT_FRAME_RATE = "30fps" DEFAULT_FRAME_RATE = "30fps"
DEFAULT_REFERENCE_VIDEO_FPS = 1 DEFAULT_REFERENCE_VIDEO_FPS = 1
# 后台配置可以控制业务字段是否启用/可编辑,但视频规格字段属于接口参数,
# 必须强制保留并锁定,避免管理端误删/禁用后导致第 4 步和第 5 步参数不一致。
VIDEO_SPEC_SECTION_KEY = "画面属性"
VIDEO_SPEC_LOCKED_FIELDS = {"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"}
ALWAYS_ALLOWED_TOP_LEVEL_KEYS = {"schema_version", "schema_usage", "动态时间规划", "输出规格限制", VIDEO_SPEC_SECTION_KEY}
TIME_PLAN_PLACEHOLDER_TEXTS = {"", "", "阶段说明", "说明", "null", "None", "none", "未提及", "不适用"}
CLIENT_SCHEMA_V1: dict[str, Any] = { CLIENT_SCHEMA_V1: dict[str, Any] = {
"schema_version": PromptSchemaVersionEnum.CLIENT_V1.value, "schema_version": PromptSchemaVersionEnum.CLIENT_V1.value,
"schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value, "schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value,
@@ -332,11 +339,14 @@ def normalize_video_prompt_schema_config(config: Any | None) -> dict[str, Any]:
if not key: if not key:
continue continue
section_type = str(section.get("type") or "object") section_type = str(section.get("type") or "object")
if key == VIDEO_SPEC_SECTION_KEY:
section_type = "object"
item: dict[str, Any] = { item: dict[str, Any] = {
"key": key, "key": key,
"label": _truncate_for_config(section.get("label") or key, 64), "label": _truncate_for_config(section.get("label") or key, 64),
"type": section_type, "type": section_type,
"enabled": _normalize_bool(section.get("enabled"), True), "enabled": True if key == VIDEO_SPEC_SECTION_KEY else _normalize_bool(section.get("enabled"), True),
"editable": _normalize_bool(section.get("editable"), 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), "max_length": _normalize_int(section.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
} }
@@ -349,17 +359,33 @@ def normalize_video_prompt_schema_config(config: Any | None) -> dict[str, Any]:
child_key = _truncate_for_config(child.get("key") or child.get("label"), 64) child_key = _truncate_for_config(child.get("key") or child.get("label"), 64)
if not child_key: if not child_key:
continue continue
is_locked_video_spec = key == VIDEO_SPEC_SECTION_KEY and child_key in VIDEO_SPEC_LOCKED_FIELDS
item["children"].append( item["children"].append(
{ {
"key": child_key, "key": child_key,
"label": _truncate_for_config(child.get("label") or child_key, 64), "label": _truncate_for_config(child.get("label") or child_key, 64),
"type": str(child.get("type") or _schema_value_type(child.get("value"))), "type": str(child.get("type") or _schema_value_type(child.get("value"))),
"enabled": _normalize_bool(child.get("enabled"), True), "enabled": True if is_locked_video_spec else _normalize_bool(child.get("enabled"), True),
"editable": _normalize_bool(child.get("editable"), True), "editable": False if is_locked_video_spec else _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), "max_length": _normalize_int(child.get("max_length") or child.get("maxLength"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
"value": copy.deepcopy(child.get("value", "")), "value": copy.deepcopy(child.get("value", "")),
} }
) )
if key == VIDEO_SPEC_SECTION_KEY:
existing_child_keys = {str(child.get("key") or "") for child in item["children"] if isinstance(child, dict)}
for locked_key in ("视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"):
if locked_key not in existing_child_keys:
item["children"].append(
{
"key": locked_key,
"label": locked_key,
"type": "string",
"enabled": True,
"editable": False,
"max_length": VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
"value": "",
}
)
elif section_type == "flow": elif section_type == "flow":
content_key = _truncate_for_config(section.get("content_key") or section.get("contentKey") or ("镜头内容" if key == "镜头流程" else "动作内容"), 64) 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") aliases = section.get("content_aliases") or section.get("contentAliases")
@@ -542,6 +568,27 @@ def build_time_plan(duration: int, schema_config_snapshot: Any | None = None) ->
) )
def _time_plan_schema_for_ai_input(plan: list[dict[str, str]]) -> list[dict[str, str]]:
"""
AI 入参 schema 只固定时间段和字段结构,不把后台配置的阶段/说明当成最终分析结果。
阶段/说明应由 AI 结合参考素材和新项目重新填写,后续归一化仅在 AI 未填写时兜底。
"""
result: list[dict[str, str]] = []
for item in plan:
result.append(
{
"时间段": item.get("时间段") or "",
"阶段": "",
"说明": "",
}
)
return result
def _time_plan_time_ranges_for_ai(plan: list[dict[str, str]]) -> list[dict[str, str]]:
return [{"时间段": item.get("时间段") or ""} for item in plan]
def build_dynamic_schema(video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]: 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) schema = build_client_schema_from_config(schema_config_snapshot)
duration = int(video_config["duration"]) duration = int(video_config["duration"])
@@ -564,11 +611,11 @@ def build_dynamic_schema(video_config: dict[str, Any], schema_config_snapshot: A
} }
) )
time_plan = build_time_plan(duration, schema_config_snapshot) time_plan = build_time_plan(duration, schema_config_snapshot)
schema["动态时间规划"] = time_plan schema["动态时间规划"] = _time_plan_schema_for_ai_input(time_plan)
# 预览/AI 入参阶段也要把流程数组按秒数切片规则初始化出来 # 预览/AI 入参阶段只锁定时间段和字段结构,不把后台阶段/说明/流程说明写死到 schema 值里
# 这样管理后台配置了 4 段/5 段规则后,动作流程、镜头流程会和动态时间规划保持相同长度, # 阶段、说明、动作内容、动作详解、镜头内容、镜头详解都应由 AI 基于素材分析填写;
# AI 生成时也能明确知道每个时间段需要填哪个流程字段 # 只有 AI 返回缺失/空/无/阶段说明等占位内容时,归一化阶段才使用后台规则兜底
for flow_key in ("动作流程", "镜头流程"): for flow_key in ("动作流程", "镜头流程"):
if flow_key not in schema: if flow_key not in schema:
continue continue
@@ -579,6 +626,7 @@ def build_dynamic_schema(video_config: dict[str, Any], schema_config_snapshot: A
content_key, content_key,
content_keys=_flow_content_aliases(schema_config_snapshot, flow_key), content_keys=_flow_content_aliases(schema_config_snapshot, flow_key),
item_fields=_flow_item_field_rules(schema_config_snapshot, flow_key), item_fields=_flow_item_field_rules(schema_config_snapshot, flow_key),
fallback_to_plan=False,
) )
schema["输出规格限制"] = { schema["输出规格限制"] = {
@@ -660,9 +708,14 @@ def build_user_text(
"参考素材": references, "参考素材": references,
"输出要求": { "输出要求": {
"生成类型": infer_generation_type(references), "生成类型": infer_generation_type(references),
"必须填充动态时间规划": build_time_plan(duration, schema_config_snapshot), "动态时间规划时间段必须严格等于": _time_plan_time_ranges_for_ai(build_time_plan(duration, schema_config_snapshot)),
"必须填充动作流程": "动作流程时间段必须覆盖完整视频时长", "动态时间规划兜底参考": build_time_plan(duration, schema_config_snapshot),
"必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长", "动态时间规划填写要求": "时间段必须和给定时间段一致;阶段、说明必须结合参考素材、新项目、核心内容点、动作和镜头重新分析填写;只有无法判断时才允许使用兜底参考;不能原样复制后台配置里的阶段说明。",
"必须填充动作流程": "动作流程时间段必须覆盖完整视频时长,每一段都必须填写动作流程对象中的全部字段。",
"动作流程字段要求": [field.get("key") for field in _flow_item_field_rules(schema_config_snapshot, "动作流程")],
"必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长,每一段都必须填写镜头流程对象中的全部字段。",
"镜头流程字段要求": [field.get("key") for field in _flow_item_field_rules(schema_config_snapshot, "镜头流程")],
"字段启用规则": "只输出 schema 中存在的启用字段;不要输出已禁用字段;不要新增 schema 外字段。",
"最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。", "最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。",
"禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"], "禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"],
}, },
@@ -917,6 +970,7 @@ def _normalize_flow_item(
content_key: str, content_key: str,
content_keys: tuple[str, ...], content_keys: tuple[str, ...],
item_fields: list[dict[str, Any]] | None = None, item_fields: list[dict[str, Any]] | None = None,
fallback_to_plan: bool = True,
) -> dict[str, str]: ) -> dict[str, str]:
item = raw_item if isinstance(raw_item, dict) else {} item = raw_item if isinstance(raw_item, dict) else {}
field_rules = item_fields or [{"key": content_key, "value": ""}] field_rules = item_fields or [{"key": content_key, "value": ""}]
@@ -924,15 +978,27 @@ def _normalize_flow_item(
base_content = _pick_flow_content(item, content_keys) base_content = _pick_flow_content(item, content_keys)
extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys) extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys)
fallback = plan_item.get("说明") or "" fallback = plan_item.get("说明") or ""
empty_fallback = "" if fallback_to_plan else ""
result: dict[str, str] = {"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or ""} result: dict[str, str] = {"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or ""}
for field in field_rules: for field in field_rules:
field_key = str(field.get("key") or "").strip() field_key = str(field.get("key") or "").strip()
if not field_key: if not field_key:
continue continue
if field_key == content_key: if field_key == content_key:
result[field_key] = _merge_flow_content(base_content, extra_texts, fallback) if fallback_to_plan:
result[field_key] = _merge_flow_content(base_content, extra_texts, fallback)
else:
result[field_key] = _merge_flow_content(base_content, extra_texts, "")
else: else:
result[field_key] = _clean_schema_text(item.get(field_key)) or _clean_schema_text(field.get("value")) or "" # AI 入参预览阶段不能把后台默认值写死到流程字段中;只输出字段结构。
# AI 返回归一化阶段才允许在缺失/无时用字段默认值或通用占位兜底。
raw_value = _clean_schema_text(item.get(field_key))
if raw_value and raw_value not in EMPTY_VALUE_TEXTS and raw_value not in TIME_PLAN_PLACEHOLDER_TEXTS:
result[field_key] = raw_value
elif fallback_to_plan:
result[field_key] = _clean_schema_text(field.get("value")) or ""
else:
result[field_key] = empty_fallback
return result return result
@@ -941,18 +1007,25 @@ def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[st
normalized: list[dict[str, str]] = [] normalized: list[dict[str, str]] = []
for index, plan_item in enumerate(plan): for index, plan_item in enumerate(plan):
raw_item = source[index] if index < len(source) and isinstance(source[index], dict) else {} raw_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
raw_stage = _clean_schema_text(raw_item.get("阶段"))
raw_desc = _clean_schema_text(raw_item.get("说明"))
if raw_stage in TIME_PLAN_PLACEHOLDER_TEXTS:
raw_stage = ""
if raw_desc in TIME_PLAN_PLACEHOLDER_TEXTS:
raw_desc = ""
# 只有时间段由服务端锁定;阶段、说明优先保留 AI 结合素材分析后的结果。
# 仅当 AI 未填写、填写“无/阶段说明/说明”等占位内容时,才使用后台时间切片规则兜底。
item: dict[str, str] = { item: dict[str, str] = {
"时间段": plan_item.get("时间段") or _clean_schema_text(raw_item.get("时间段")) or "", "时间段": plan_item.get("时间段") or _clean_schema_text(raw_item.get("时间段")) or "",
"阶段": _clean_schema_text(raw_item.get("阶段")) or plan_item.get("阶段") or "", "阶段": raw_stage or plan_item.get("阶段") or "",
"说明": _clean_schema_text(raw_item.get("说明")) or plan_item.get("说明") or "", "说明": raw_desc or plan_item.get("说明") or "",
} }
# 动态时间规划只保留 时间段/阶段/说明,多余字段不入库。
normalized.append(item) normalized.append(item)
return normalized return normalized
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any]: 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) base_plan = build_time_plan(duration, schema_config_snapshot)
plan = _normalize_time_plan(base_plan, result.get("动态时间规划"))
action_key = _flow_content_key(schema_config_snapshot, "动作流程") action_key = _flow_content_key(schema_config_snapshot, "动作流程")
camera_key = _flow_content_key(schema_config_snapshot, "镜头流程") camera_key = _flow_content_key(schema_config_snapshot, "镜头流程")
result["动作流程"] = _align_flow_time_ranges( result["动作流程"] = _align_flow_time_ranges(
@@ -969,7 +1042,7 @@ def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int, schema_
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"), content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"), item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
) )
result["动态时间规划"] = _normalize_time_plan(plan, result.get("动态时间规划")) result["动态时间规划"] = plan
return result return result
@@ -1071,6 +1144,7 @@ def _align_flow_time_ranges(
*, *,
content_keys: tuple[str, ...] | None = None, content_keys: tuple[str, ...] | None = None,
item_fields: list[dict[str, Any]] | None = None, item_fields: list[dict[str, Any]] | None = None,
fallback_to_plan: bool = True,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
source = flow if isinstance(flow, list) else [] source = flow if isinstance(flow, list) else []
aliases = content_keys or (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)
@@ -1084,6 +1158,7 @@ def _align_flow_time_ranges(
content_key=default_content_key, content_key=default_content_key,
content_keys=aliases, content_keys=aliases,
item_fields=item_fields, item_fields=item_fields,
fallback_to_plan=fallback_to_plan,
) )
) )
return aligned return aligned
@@ -1178,6 +1253,26 @@ def _merge_flow_patch(base_flow: Any, patch_flow: Any, flow_key: str, schema_con
return result return result
def _prune_schema_by_runtime_config(schema: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
default_schema = build_client_schema_from_config(schema_config_snapshot)
allowed_top_keys = set(default_schema.keys()) | ALWAYS_ALLOWED_TOP_LEVEL_KEYS
pruned: dict[str, Any] = {}
for key, value in schema.items():
if key not in allowed_top_keys:
continue
if isinstance(value, dict) and isinstance(default_schema.get(key), dict):
allowed_fields = set(default_schema[key].keys())
if key == VIDEO_SPEC_SECTION_KEY:
allowed_fields |= VIDEO_SPEC_LOCKED_FIELDS
pruned[key] = {field_key: field_value for field_key, field_value in value.items() if field_key in allowed_fields}
else:
pruned[key] = value
if VIDEO_SPEC_SECTION_KEY not in pruned or not isinstance(pruned.get(VIDEO_SPEC_SECTION_KEY), dict):
pruned[VIDEO_SPEC_SECTION_KEY] = {}
return pruned
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> 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"]) duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"]) video_ratio = str(video_config["aspect_ratio"])
@@ -1204,32 +1299,39 @@ def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[
} }
) )
schema["动态时间规划"] = plan schema["动态时间规划"] = _normalize_time_plan(plan, schema.get("动态时间规划"))
schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {}) schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {})
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 = build_client_schema_from_config(schema_config_snapshot) default_schema = build_client_schema_from_config(schema_config_snapshot)
if "动作流程" in default_schema:
schema["动作流程"] = _align_flow_time_ranges(
schema.get("动作流程"),
schema["动态时间规划"],
_flow_content_key(schema_config_snapshot, "动作流程"),
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
)
else:
schema.pop("动作流程", None)
if "镜头流程" in default_schema:
schema["镜头流程"] = _align_flow_time_ranges(
schema.get("镜头流程"),
schema["动态时间规划"],
_flow_content_key(schema_config_snapshot, "镜头流程"),
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
)
else:
schema.pop("镜头流程", None)
# 合规和质量控制不能被前端降低;仅当配置启用对应分组时才补齐,禁用分组必须从最终 schema 中移除。
if not isinstance(schema.get("合规控制"), dict) and isinstance(default_schema.get("合规控制"), dict): if not isinstance(schema.get("合规控制"), dict) and isinstance(default_schema.get("合规控制"), dict):
schema["合规控制"] = copy.deepcopy(default_schema["合规控制"]) schema["合规控制"] = copy.deepcopy(default_schema["合规控制"])
if not isinstance(schema.get("质量控制"), dict) and isinstance(default_schema.get("质量控制"), dict): if not isinstance(schema.get("质量控制"), dict) and isinstance(default_schema.get("质量控制"), dict):
schema["质量控制"] = copy.deepcopy(default_schema["质量控制"]) schema["质量控制"] = copy.deepcopy(default_schema["质量控制"])
return clean_final_prompt_specs(schema, video_config) return _prune_schema_by_runtime_config(clean_final_prompt_specs(schema, video_config), schema_config_snapshot)
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]: 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]: